TheArchitect

CQRS + Event Sourcing

AWS architecture template · Command and Query Responsibility Segregation with DynamoDB Streams as the event bus. Commands append immutable events; a projector builds a denormalised read model.

CQRS + Event Sourcing is a free AWS architecture template built with Lambda, DynamoDB, IAM, API Gateway, Athena, CloudWatch and S3. Open it in TheArchitect to edit the diagram, estimate monthly cost, and export the CloudFormation or Terraform that deploys it.

Command + Quer…Command HandlerEvent StoreProjectorRead ModelQuery HandlerEvent ArchiveReplay & AuditCommand RoleProjector RoleProjection Lag…
CQRS + Event Sourcing architecture diagram

AWS services in this template

  • Lambda×3
  • DynamoDB×2
  • IAM×2
  • API Gateway
  • Athena
  • CloudWatch
  • S3

Deployment notes

Needs input

  • Enable DynamoDB Streams on the event store table (NEW_IMAGE) before deploying projector Lambda.
  • Create the read model GSI for the query access pattern.

Generated CloudFormation — 12 resources

Logical IDResource type
CommandQueryAPIapigatewayAWS::ApiGateway::RestApi
CommandHandlerlambdaAWS::Lambda::Function
EventStoredynamodbAWS::DynamoDB::Table
ProjectorlambdaAWS::Lambda::Function
ReadModeldynamodbAWS::DynamoDB::Table
QueryHandlerlambdaAWS::Lambda::Function
EventArchives3AWS::S3::Bucket
ReplayAuditathenaAWS::Athena::WorkGroup
CommandRoleiamAWS::IAM::Role
ProjectorRoleiamAWS::IAM::Role
ProjectionLagAlarmcloudwatchAWS::CloudWatch::Alarm
ProjectorlambdaEventArchives3PermissionAWS::Lambda::Permission
View the full CloudFormation template
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "Generated by TheArchitect — https://www.thecloudarchitect.dev",
  "Metadata": {
    "TheArchitect": {
      "Edges": [
        {
          "From": "CommandHandlerlambda",
          "To": "EventStoredynamodb",
          "Label": "conditional append"
        },
        {
          "From": "EventStoredynamodb",
          "To": "Projectorlambda",
          "Label": "DynamoDB Stream"
        },
        {
          "From": "Projectorlambda",
          "To": "ReadModeldynamodb",
          "Label": "upsert projection"
        },
        {
          "From": "Projectorlambda",
          "To": "EventArchives3",
          "Label": "archive event"
        },
        {
          "From": "QueryHandlerlambda",
          "To": "ReadModeldynamodb",
          "Label": "read projection"
        },
        {
          "From": "EventArchives3",
          "To": "ReplayAuditathena",
          "Label": "audit queries"
        },
        {
          "From": "CommandRoleiam",
          "To": "CommandHandlerlambda",
          "Label": "execution role"
        },
        {
          "From": "ProjectorRoleiam",
          "To": "Projectorlambda",
          "Label": "execution role"
        },
        {
          "From": "Projectorlambda",
          "To": "ProjectionLagAlarmcloudwatch",
          "Label": "iterator age"
        }
      ]
    }
  },
  "Resources": {
    "CommandQueryAPIapigateway": {
      "Type": "AWS::ApiGateway::RestApi",
      "Properties": {
        "Name": "cqrs-api",
        "Description": "Unified command and query REST API",
        "EndpointConfiguration": {
          "Types": [
            "REGIONAL"
          ]
        }
      }
    },
    "CommandHandlerlambda": {
      "Type": "AWS::Lambda::Function",
      "DependsOn": [
        "CommandRoleiam"
      ],
      "Properties": {
        "FunctionName": "cqrs-command-handler",
        "Runtime": "nodejs20.x",
        "Handler": "command.handler",
        "MemorySize": 512,
        "Timeout": 15,
        "Description": "Validates command, appends immutable event to event store using conditional write on (aggregate_id, version) to prevent duplicate events.",
        "Role": {
          "Fn::GetAtt": [
            "CommandRoleiam",
            "Arn"
          ]
        }
      }
    },
    "EventStoredynamodb": {
      "Type": "AWS::DynamoDB::Table",
      "Properties": {
        "TableName": "event-store",
        "BillingMode": "PAY_PER_REQUEST",
        "AttributeDefinitions": [
          {
            "AttributeName": "id",
            "AttributeType": "S"
          }
        ],
        "KeySchema": [
          {
            "AttributeName": "id",
            "KeyType": "HASH"
          }
        ],
        "PointInTimeRecoverySpecification": {
          "PointInTimeRecoveryEnabled": true
        }
      }
    },
    "Projectorlambda": {
      "Type": "AWS::Lambda::Function",
      "DependsOn": [
        "ProjectorRoleiam"
      ],
      "Properties": {
        "FunctionName": "cqrs-projector",
        "Runtime": "nodejs20.x",
        "Handler": "projector.handler",
        "MemorySize": 512,
        "Timeout": 60,
        "Description": "Triggered by DynamoDB Stream (NEW_IMAGE). Rebuilds the read model projection in the read model table and archives the event to S3.",
        "Role": {
          "Fn::GetAtt": [
            "ProjectorRoleiam",
            "Arn"
          ]
        }
      }
    },
    "ReadModeldynamodb": {
      "Type": "AWS::DynamoDB::Table",
      "Properties": {
        "TableName": "read-model",
        "BillingMode": "PAY_PER_REQUEST",
        "AttributeDefinitions": [
          {
            "AttributeName": "id",
            "AttributeType": "S"
          }
        ],
        "KeySchema": [
          {
            "AttributeName": "id",
            "KeyType": "HASH"
          }
        ]
      }
    },
    "QueryHandlerlambda": {
      "Type": "AWS::Lambda::Function",
      "Properties": {
        "FunctionName": "cqrs-query-handler",
        "Runtime": "nodejs20.x",
        "Handler": "query.handler",
        "MemorySize": 256,
        "Timeout": 10,
        "Description": "Reads exclusively from the denormalised read model. No access to the event store — enforces CQRS separation."
      }
    },
    "EventArchives3": {
      "Type": "AWS::S3::Bucket",
      "Properties": {
        "BucketName": "cqrs-event-archive",
        "VersioningConfiguration": {
          "Status": "Enabled"
        },
        "PublicAccessBlockConfiguration": {
          "BlockPublicAcls": true,
          "BlockPublicPolicy": true,
          "IgnorePublicAcls": true,
          "RestrictPublicBuckets": true
        },
        "NotificationConfiguration": {
          "LambdaConfigurations": [
            {
              "Event": "s3:ObjectCreated:*",
              "Function": {
                "Fn::GetAtt": [
                  "Projectorlambda",
                  "Arn"
                ]
              }
            }
          ]
        }
      },
      "DependsOn": [
        "ProjectorlambdaEventArchives3Permission"
      ]
    },
    "ReplayAuditathena": {
      "Type": "AWS::Athena::WorkGroup",
      "Properties": {
        "Name": "cqrs-replay-audit",
        "Description": "Replay and audit queries against the immutable event archive",
        "State": "ENABLED",
        "WorkGroupConfiguration": {
          "PublishCloudWatchMetricsEnabled": false,
          "BytesScannedCutoffPerQuery": 5368709120,
          "ResultConfiguration": {
            "OutputLocation": "s3://cqrs-event-archive/athena-results/"
          }
        }
      }
    },
    "CommandRoleiam": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "RoleName": "cqrs-command-role",
        "Description": "Command handler Lambda role — event-store DynamoDB write only",
        "AssumeRolePolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Principal": {
                "Service": "lambda.amazonaws.com"
              },
              "Action": "sts:AssumeRole"
            }
          ]
        },
        "ManagedPolicyArns": [
          "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
        ]
      }
    },
    "ProjectorRoleiam": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "RoleName": "cqrs-projector-role",
        "Description": "Projector Lambda role — DynamoDB stream read, read-model write, S3 archive write",
        "AssumeRolePolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Principal": {
                "Service": "lambda.amazonaws.com"
              },
              "Action": "sts:AssumeRole"
            }
          ]
        },
        "ManagedPolicyArns": [
          "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
        ]
      }
    },
    "ProjectionLagAlarmcloudwatch": {
      "Type": "AWS::CloudWatch::Alarm",
      "Properties": {
        "AlarmName": "cqrs-projection-lag",
        "AlarmDescription": "DynamoDB Streams IteratorAge > 30 s — projector is falling behind commands",
        "Namespace": "AWS/Lambda",
        "MetricName": "IteratorAge",
        "Statistic": "Maximum",
        "Period": 60,
        "EvaluationPeriods": 3,
        "Threshold": 30000,
        "ComparisonOperator": "GreaterThanThreshold",
        "TreatMissingData": "notBreaching"
      }
    },
    "ProjectorlambdaEventArchives3Permission": {
      "Type": "AWS::Lambda::Permission",
      "DependsOn": [
        "Projectorlambda"
      ],
      "Properties": {
        "Action": "lambda:InvokeFunction",
        "FunctionName": {
          "Ref": "Projectorlambda"
        },
        "Principal": "s3.amazonaws.com",
        "SourceAccount": {
          "Ref": "AWS::AccountId"
        },
        "SourceArn": {
          "Fn::Sub": "arn:${AWS::Partition}:s3:::cqrs-event-archive"
        }
      }
    }
  }
}

Generated by TheArchitect from the diagram above. Review values such as names, CIDR ranges and instance sizes before deploying to your account.

Related templates