KgwariC4 Architecture
02

Kgwari APIkgwari-backend-app · TypeScript · AWS CDK · Serverless

AWS CDK v2 15 routes + a projector API Gateway v2 DynamoDB ×8 Cognito passkeys OpenSearch Powertools

2A — ContextSystem boundary

An HTTP/JSON API consumed by the Kgwari client. Identity is delegated to Cognito in both directions — the API verifies its JWTs, and Cognito calls back into a post-confirmation Lambda to create the member record.

be-context diagram
PlantUML source
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml

LAYOUT_WITH_LEGEND()
HIDE_STEREOTYPE()
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "Inter"
skinparam shadowing false

title Kgwari API — System Context

Person(member, "Member", "Authenticated end user")
Person_Ext(dev, "Developer / CI", "Deploys per-env CDK stacks; seeds dev data")

System(backend, "Kgwari API", "kgwari-backend-app\nServerless TypeScript (dev · uat · beta · production)\n9 feature domains · 15 routes + a stream projector · 8 tables")

System_Ext(frontend, "Kgwari Client", "Expo web / iOS / Android")
System_Ext(cognito, "Amazon Cognito", "User pool · passkeys (verification REQUIRED)\nOTP broker · JWT issuer")
System_Ext(opensearch, "Amazon OpenSearch", "The search ledger — managed or serverless\nper environment, behind one adapter seam")
System_Ext(translate, "Amazon Translate", "On-demand translation of members' own words")
System_Ext(bedrock, "Amazon Bedrock", "Wine-label parsing. NOT localization —\nthat is the frontend's build-time job")
System_Ext(gha, "GitHub Actions", "OIDC-assumed deploy role · matrix per environment")
System_Ext(ghpkg, "GitHub Packages", "@edwardseshoka contracts · foundation · samples")

Rel(member, frontend, "Uses")
Rel(frontend, backend, "Calls", "HTTPS / JSON · Bearer JWT")
Rel(backend, cognito, "Validates JWTs · brokers OTP sign-in", "JWKS · cognito-idp admin")
Rel(cognito, backend, "Post-confirmation trigger writes the member record", "Lambda")
Rel(backend, opensearch, "Queries · indexes the projection", "SigV4 over REST")
Rel(backend, translate, "TranslateText", "cached per content hash")
Rel(backend, bedrock, "InvokeModel", "label parsing")
Rel(dev, gha, "Pushes to main / dispatches deploy")
Rel(gha, backend, "Synthesises & deploys CDK app", "AWS API")
Rel(dev, backend, "Seeds dev tables", "npm run seed:dev")
Rel_Back(backend, ghpkg, "Resolves shared packages at build", "npm install")

@enduml

2B — ContainerSeven CDK stacks · fifteen routes · eight tables

Every route Lambda's IAM grants and HTTP path are derived from the same row of a declarative table, so a route cannot be wired without the table access it needs. Two Lambdas are not routes at all: the Cognito post-confirmation trigger, and the projector that turns four table streams into search rows — see 04 / Search.

be-container diagram
PlantUML source
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml

LAYOUT_WITH_LEGEND()
HIDE_STEREOTYPE()
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "Inter"
skinparam shadowing false

title Kgwari API — Containers (per environment: kgwari-<env>, eu-west-1)

Person(member, "Authenticated member")
System_Ext(frontend, "Kgwari Client", "Expo client")

System_Boundary(be, "kgwari-backend-app — kgwari-<env>") {

  Container_Boundary(authstack, "auth-stack") {
    Container(userpool, "Cognito User Pool", "Amazon Cognito ESSENTIALS", "Passkeys — user verification REQUIRED\nJWT issuer · JWKS")
    Container(postconfirm, "PostConfirmation trigger", "Lambda", "Writes the member record on sign-up")
  }

  Container_Boundary(apistack, "api-stack") {
    Container(apigw, "HTTP API Gateway", "API Gateway v2", "JWT authorizer -> Cognito · custom domain")

    Container_Boundary(lambdas, "15 route Lambdas — one bundle, one declarative route table") {
      Container(catalogL, "Catalog", "4 lambda", "GET /public/wines · /public/featuredwine\n/public/collections · GET /wines/{id}")
      Container(cellarL, "Cellar", "3 lambda · auth", "GET /cellar · POST /cellar · GET /cellar/{id}")
      Container(discoverL, "getDiscover", "1 lambda", "GET /discover — reads 6 tables")
      Container(searchL, "Search", "3 lambda · public", "GET /search · /search/suggest · /search/browse\n29s timeout on serverless only, to absorb\nthe collection's cold start")
      Container(memberL, "Member", "2 lambda · auth", "GET /members/me (alias /user/profile)\nPATCH /members/me — partial by design")
      Container(otpL, "OTP broker", "2 lambda · public", "POST /auth/otp/start · /auth/otp/verify\notpSession handle is the binding")
    }
  }

  Container_Boundary(datastack, "data-stack") {
    ContainerDb(ddb, "8 DynamoDB tables", "Amazon DynamoDB", "wines · members · provenance · editorial\nevents · social · discover-plan · search\nNames derived from one suffix map — no Fn::ImportValue")
    Container(projector, "projectSearchRows", "Lambda · ONE function, FOUR streams", "wines · provenance · events · members\nTRIM_HORIZON · batchSize 10\nreportBatchItemFailures + bisect on error")
  }

  Container_Boundary(searchstack, "search-stack — its own stack, because a domain takes 15-30 min to change") {
    Container(engine, "OpenSearch", "managed | serverless | none", "beta & production: managed domain\ndev & uat: NextGen collection, scales to zero\nSelected by EnvironmentConfiguration.search.flavour")
  }

  Container_Boundary(webstack, "web-stack") {
    ContainerDb(s3, "Web Bucket", "S3", "Hosts the Expo web export")
    Container(cf, "CloudFront Distribution", "CDN", "Exports BucketName & DistributionId")
  }

  Container(seed, "seedSampleData", "Node script", "npm run seed:dev — writes @edwardseshoka/samples\ninto the dev tables")
}

System_Ext(ghpkg, "GitHub Packages")

Rel(member, frontend, "Uses")
Rel(frontend, apigw, "REST", "HTTPS · Bearer JWT")
Rel(apigw, userpool, "Verifies JWT", "JWKS")
Rel(apigw, catalogL, "routes")
Rel(apigw, cellarL, "routes", "authorized")
Rel(apigw, discoverL, "routes")
Rel(apigw, searchL, "routes", "public")
Rel(apigw, memberL, "routes", "authorized")
Rel(apigw, otpL, "routes", "public")
Rel(catalogL, ddb, "Query / Get / Put", "AWS SDK v3")
Rel(cellarL, ddb, "Query / Get / Put")
Rel(discoverL, ddb, "Query x6 pools")
Rel(searchL, engine, "Search", "SigV4 · `es` managed, `aoss` serverless")
Rel(ddb, projector, "Streams changes to", "NEW_IMAGE")
Rel(projector, ddb, "Writes search rows", "write grant only")
Rel(memberL, ddb, "Get / Update members")
Rel(otpL, userpool, "AdminGetUser · AdminCreateUser", "cognito-idp")
Rel(otpL, ddb, "Reads & writes members")
Rel(userpool, postconfirm, "Invokes")
Rel(postconfirm, ddb, "Writes member")
Rel(seed, ddb, "BatchWrite", "dev only")
Rel_U(ghpkg, seed, "Provides sample content", "build-time")
Rel(cf, s3, "Origin")

@enduml

2C — ComponentInside GET /discover

The one route that reads across six domains. The use case fans out six repository reads concurrently; a pure function then arranges the results into the feed. Nothing in the composer fetches, so the whole arrangement is testable without AWS.

be-component diagram
PlantUML source
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml

LAYOUT_WITH_LEGEND()
HIDE_STEREOTYPE()
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "Inter"
skinparam shadowing false

title Kgwari API — Component view of GET /discover (the cross-domain read)

Container_Boundary(presentation, "discover/presentation") {
  Component(handler, "makeGetDiscoverHandler(components)", "Lambda handler", "Thin adapter — no branching beyond ok() / mapError()")
  Component(respMapper, "toDiscoverContract", "Mappers", "Entity -> wire; hero · doorway · response mappers")
  Component(routes, "discover.routes.ts", "Route constant", "The one place the path GET /discover is written")
}

Container_Boundary(composition, "Composition root — main/app/composition") {
  Component(appComp, "AppComposition", "Factory", "Frozen DI graph built once per cold start")
  Component(featureComp, "Feature compositions", "TS", "catalog · discover · member — each wires its own repositories")
  Component(envCfg, "loadRuntimeEnvironmentConfiguration", "core/config", "Table names, region, user pool — read once")
}

Container_Boundary(domain, "discover/domain (+ five sibling domains)") {
  Component(usecase, "GetDiscoverUseCase", "NoInputUseCase<Discover>", "Fans out six repository reads with Promise.all")
  Component(composer, "composeDiscover", "Pure function", "Resolves the plan against the pools and orders the\nFade Yield funnel: editorial · wines · doorways · events · room")
  Component(entities, "Entities", "TS", "Discover · DiscoverPlan · DiscoverGroup\nDiscoverHighlight · Doorway")
  Component(ports, "Repository ports", "Interfaces", "plan · wines · regions · editorial · events · activities")
}

Container_Boundary(dataLayer, "*/data — one adapter pair per domain") {
  Component(repos, "Dynamo*Repository", "Adapters", "Implement the six ports")
  Component(services, "Dynamo*Service", "Table access", "Query / Get / Put against a single table each")
  Component(recMappers, "Record mappers", "TS", "DynamoDB item <-> Entity")
}

Container_Boundary(core, "packages/core — eleven shared packages") {
  Component(lambdaHttp, "lambda-http", "TS", "response · error.middleware · validate.middleware\napiGatewayEvent · JwtClaims")
  Component(persistCore, "persistence", "TS", "partitionTable · fieldReaders")
  Component(dynClient, "dynamodb", "@aws-sdk/lib-dynamodb", "Memoised document client")
  Component(obs, "observability", "Powertools", "Structured JSON logger")
  Component(domainCore, "domain", "TS", "Verdict · ProvenanceState · trust vocabulary")
}

ContainerDb_Ext(ddb, "6 DynamoDB tables", "wines · provenance · editorial\nevents · social · discover-plan")
System_Ext(contracts, "@edwardseshoka/contracts", "Wire DTOs + trust vocabulary")

Rel(routes, handler, "Names the path for")
Rel(handler, appComp, "Receives DI graph from")
Rel(appComp, featureComp, "Delegates wiring to")
Rel(appComp, envCfg, "Reads runtime config")
Rel(featureComp, repos, "Wires concrete adapters")
Rel(handler, usecase, "Invokes")
Rel(usecase, ports, "Depends on")
Rel(usecase, composer, "Arranges with")
Rel(composer, entities, "Produces")
Rel(composer, domainCore, "Ranks by Verdict from")
Rel(repos, ports, "Implement")
Rel(repos, services, "Delegate to")
Rel(repos, recMappers, "Map with")
Rel(services, persistCore, "Partition keys & field readers")
Rel(services, dynClient, "Uses")
Rel(dynClient, ddb, "Query", "HTTPS")
Rel(handler, respMapper, "Serialises through")
Rel(respMapper, contracts, "Shapes to DTOs in")
Rel(handler, lambdaHttp, "ok() / mapError()")
Rel(handler, obs, "Logs through")

@enduml
Technical Summary

A serverless TypeScript backend of nine feature domains — catalog, collections, discover, editorial, events, member, provenance, search, social — each split into data, domain and presentation, sitting on eleven packages/core libraries for HTTP adaptation, persistence, configuration, observability, the OpenSearch and Bedrock and Translate clients, and the shared trust vocabulary. Seven CDK stacks (auth, data, api, search, dns, web-certificate, web) provision it into four account-isolated environments in eu-west-1.

The infrastructure is written to remove whole classes of drift rather than to be clever. Physical table names come from one suffix map that both the creating and the consuming stack derive from, and consumers reference tables by derived name instead of Fn::ImportValue — which is what lets a data-stack change proceed without deadlocking its dependants. Routes, Lambdas and grants come from a single declarative table. A moved resource keeps its old address through an explicit alias path rather than a duplicated function, so already-shipped clients keep working while the new one ships.