DEV Community

Cover image for DynamoDB Single Table Design
Massimo Biagioli for Claranet

Posted on

DynamoDB Single Table Design

One Table, Many Possibilities: Exploring Single Table Design

Dive into the realm of DynamoDB's Single Table Design to demystify the complexities of database management. Say farewell to convoluted modeling, as DynamoDB's intrinsic capabilities empower developers to effortlessly handle a myriad of entities within a unified table structure. This article guides you through the process, highlighting how embracing a straightforward approach to DynamoDB usage can significantly enhance the efficiency of your development journey. Discover the power of simplicity in database design and streamline your DynamoDB experience for optimal results.

Use Case: Modeling Entities in DynamoDB's Single Table Design

In our scenario, where entities like "Area," "Network," and "Device" coexist, we leverage DynamoDB's Single Table Design to seamlessly organize and retrieve this interconnected data.
By adopting this approach, you harness DynamoDB's inherent flexibility, allowing you to navigate through your data effortlessly. This design choice aligns with the philosophy of Single Table Design, emphasizing simplicity and ease of use in modeling complex relationships within DynamoDB.

Essential DynamoDB Knowledge: Unraveling Key Concepts

  • Partition Key: In DynamoDB, each item in a table is uniquely identified by a primary key, which consists of a partition key. The partition key is used to distribute data across multiple partitions for scalability. It's crucial for efficient data retrieval, especially when working with large datasets.

  • Sort Key (Range Key): The primary key in DynamoDB can also include a sort key (or range key). This adds a second level of organization, allowing items with the same partition key to be sorted and stored together. It enables powerful querying and sorting operations within a partition.

  • Global Secondary Index (GSI): GSIs in DynamoDB provide an alternative way to query your data. They allow you to create additional indexes outside the primary key, enabling different access patterns. A GSI has its own partition key and sort key, distinct from the table's primary key.

  • Local Secondary Index (LSI): Similar to a GSI, an LSI is an index that allows you to query data in ways other than the table's primary key. However, LSIs share the same partition key as the table but have a different sort key. LSIs can only be created when designing the table and cannot be added later.

Understanding and appropriately leveraging these DynamoDB concepts is essential for designing efficient and scalable data models based on your application's specific needs.

Introducing DynamoDB Toolbox

Unlocking the true potential of DynamoDB's Single Table Design has never been easier, thanks to DynamoDB Toolbox. With the motto "Single Table Designs have never been this easy!" this powerful library streamlines the process of implementing efficient and scalable data models.

In our example, we seamlessly integrated DynamoDB Toolbox into a Fastify application, showcasing the synergy between a robust web framework and the simplicity of Single Table Design. This integration empowers developers to focus on building features rather than wrestling with database complexities.

Explore the capabilities of DynamoDB Toolbox as we demonstrate how it aligns with the philosophy of Single Table Design, making DynamoDB development a smoother and more enjoyable experience. Whether you're a seasoned developer or just starting with DynamoDB, this toolbox is your companion for simplifying and enhancing your Single Table Design journey.

Organizing Data

Image description

Scan all areas

Image description

Query a single area

Image description

Query a single area with newtworks

Image description

Query all networks by area

Image description

Query all network devices (using GSI)

Image description

Fastify App

We kick off by creating a simple Fastify application in TypeScript.

src/app.ts

...imports

export default function createApp(
  opts?: FastifyServerOptions,
): FastifyInstance {
  const defaultOptions = {
    logger: true,
  }

  const app = fastify({ ...defaultOptions, ...opts })

  app.register(swagger, {
    ...
  })

  app.register(swaggerUI)

  app.register(cors)

  app.register(autoload, {
    dir: join(__dirname, 'core'),
  })

  app.register(autoload, {
    dir: join(__dirname, 'services'),
  })

  app.register(autoload, {
    dir: join(__dirname, 'routes'),
    options: { prefix: '/api' },
    routeParams: true
  })

  return app
}
Enter fullscreen mode Exit fullscreen mode

DynamoDB Client Plugin

With the DynamoDB Client Plugin, you can effortlessly map and expose various entities within your Fastify application, following the principles of Single Table Design. This streamlined integration empowers developers to focus on building robust features, abstracting away the complexities of DynamoDB interactions.

src/core/dynamodb-client-plugin.ts

...imports...

const marshallOptions = {
  convertEmptyValues: false,
  removeUndefinedValues: false,
  convertClassInstanceToMap: false,
}

const unmarshallOptions = {
  wrapNumbers: false,
}

const translateConfig = { marshallOptions, unmarshallOptions }

const DocumentClient = DynamoDBDocumentClient.from(new DynamoDBClient({}), translateConfig)

const SingleTableDemo = new Table({
  name: 'single-table-demo',
  partitionKey: 'pk',
  sortKey: 'sk',
  indexes: {
    gsi1: { partitionKey: 'gsi1pk', sortKey: 'gsi1sk' }
  },
  DocumentClient
})

const AreaEntity = new Entity({
  name: 'AREA',

  attributes: {
    id: {
      partitionKey: true,
      default: (data: { areaId: string, _et: string }) => `${data._et}#${data.areaId}`
    },
    sk: {
      sortKey: true,
      default: (data: { areaId: string, _et: string }) => `${data._et}#${data.areaId}`
    },
    name: { type: 'string', required: true, map: 'areaName' },
    areaId: { type: 'string', required: true },
    manager: { type: 'string', required: true },
    location: { type: 'string', required: true },
  },

  table: SingleTableDemo
} as const)

const NetworkEntity = new Entity({
  name: 'NETWORK',

  attributes: {
    id: {
      partitionKey: true ,
      default: (data: { areaId: string }) => `AREA#${data.areaId}`
    },
    sk: {
      sortKey: true,
      default: (data: { _et: string, areaId: string, networkId: string }) => `AREA#${data.areaId}#${data._et}#${data.networkId}`
    },
    areaId: { type: 'string', required: true },
    networkId: { type: 'string', required: true },
    networkType: { type: 'string', required: true },
    connectionSpeed: { type: 'string', required: true },
  },

  table: SingleTableDemo
} as const)

const DeviceEntity = new Entity({
  name: 'DEVICE',

  attributes: {
    id: { partitionKey: true },
    sk: {
      sortKey: true,
      default: (data: { id: string }) => `${data.id}`
    },
    gsi1pk: { type: 'string', required: true },
    gsi1sk: { type: 'string', required: true },
    type: { type: 'string', required: true },
    deviceId: { type: 'string', required: true },
    deviceName: { type: 'string', required: true },
    deviceType: { type: 'string', required: true },
    IPAddress: { type: 'string', required: true },
  },

  table: SingleTableDemo
} as const)

async function dynamoDbClientPlugin(app: FastifyInstance): Promise<void> {
  app.decorate('dynamoDbClient', {
    Area: AreaEntity,
    Network: NetworkEntity,
    Device: DeviceEntity,
  })
}

export default fp(dynamoDbClientPlugin)
Enter fullscreen mode Exit fullscreen mode

AreaService Plugin

The AreaService Plugin acts as a dedicated API gateway, exposing seamless interfaces to interact with the "Area" entity. Integrated into our Fastify application, this plugin simplifies the management of areas within the Single Table Design DynamoDB structure. Developers can effortlessly perform CRUD operations on areas, abstracting away the intricacies of database interactions and ensuring a cohesive and efficient development experience.

src/services/area.service.ts

...imports...

async function areaServicePlugin(app: FastifyInstance): Promise<void> {

  function mapItemToArea(item: Record<string, string>): Area {
    return {
      id: item.areaId,
      name: item.name,
      manager: item.manager,
      location: item.location,
    }
  }

  async function getAll(): Promise<AreaCollection> {
    const result = await app.dynamoDbClient.Area.scan({
      filters: [
        {
          attr: '_et',
          eq: 'AREA'
        },
      ],
    })

    return result?.Items?.map(mapItemToArea) || []
  }

  async function get(id: string): Promise<Area | null> {
    const pk = `AREA#${id}`
    const { Item} = await app.dynamoDbClient.Area.get({
      id: pk,
      sk: pk,
    })

    if (!Item) {
      return null
    }

    return mapItemToArea(Item)
  }

  async function put(area: Area): Promise<Area> {
    const result = await app.dynamoDbClient.Area.put({
      areaId: area.id,
      name: area.name,
      manager: area.manager,
      location: area.location,
    })

    if (result.$metadata.httpStatusCode !== 200) {
      throw new Error('Error creating area')
    }

    return area
  }

  app.decorate('areaService', {
    getAll,
    get,
    put,
  })
}

export default fp(areaServicePlugin)
Enter fullscreen mode Exit fullscreen mode

Utilizing AreaService in the "GET Areas" Route

Let's dive into the practical implementation of the AreaService within the "GET Areas" route. Leveraging the Fastify application and the DynamoDB Client Plugin, our AreaService seamlessly fetches and exposes areas through a straightforward API call.

Developers can now witness the simplicity of retrieving area entities without the need for complex database queries. This integration showcases how the AreaService abstracts away the underlying DynamoDB interactions, allowing for a clean and concise implementation of the "GET Areas" route within our Fastify application. Explore the elegance of Single Table Design in action as we demonstrate the power and efficiency of the AreaService plugin.

src/routes/area/list.route.ts

import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import { AreaCollection } from '@models/area.model'

const route: FastifyPluginAsyncTypebox = async function (app) {
  app.get<{ Reply: AreaCollection }>(
    '/',
    {
      schema: {
        tags: ['Area'],
        response: {
          200: AreaCollection,
          500: {
            type: 'null',
            description: 'Internal server error',
          },
        },
      },
    },
    async (req, reply) => {
      const areas = await app.areaService.getAll()
      reply.code(200).send(areas)
    },
  )
}

export default route
Enter fullscreen mode Exit fullscreen mode

Request:

curl --location '<server-url>/api/area'
Enter fullscreen mode Exit fullscreen mode

Response:

[
    {
        "id": "1000",
        "name": "WLF",
        "manager": "Clifford Smith",
        "location": "Townebury"
    },
    {
        "id": "2000",
        "name": "CRI",
        "manager": "Vera Kozey",
        "location": "North Abdiel"
    },
    {
        "id": "3000",
        "name": "DOM",
        "manager": "Philip Thompson",
        "location": "Camden"
    }
]
Enter fullscreen mode Exit fullscreen mode

Conclusions

In adopting the Single Table Design approach, we unlock DynamoDB's full potential without compromising on data modeling. This streamlined methodology, exemplified through the integration of DynamoDB Toolbox and the AreaService plugin, ensures efficient data organization within our Fastify application.

By strategically structuring entities and relationships, Single Table Design proves instrumental in maximizing DynamoDB's scalability and performance. It simplifies development, offering a clear and intuitive path to fully leverage DynamoDB's capabilities. In essence, this approach signifies a harmonious blend of powerful database functionality and effective data modeling, transforming the development landscape.

The project code is in this GitHub repository: fastify-dynamodb-single-table.

Top comments (0)