← Workshops Β· Part 5 Β· Python API

Backend Option B: Python API with AWS SAM

A standalone serverless API on AWS β€” API Gateway + Lambda (Python) + DynamoDB, defined as infrastructure-as-code and deployed with one command. One of three backend options.

πŸ“ Test yourself ↓
28 min readPython APIRead or present

What you'll build

The same Notes API as a standalone serverless backend on AWS β€” API Gateway + Lambda (Python) + DynamoDB in one SAM template β€” built the way JunctionNet ships production APIs: a clean layered architecture with a middleware factory for dependency injection.

🧭

Request β†’ handler β†’ middleware (injects services) β†’ service β†’ repository β†’ model. Each layer has one job; a factory wires the dependencies once per request.

πŸ”€

This is backend Option B. Prefer it inside your Next.js app? Part 4 Β· Node.js. Prefer a long-running container? Part 6 Β· Docker + ECS Fargate.

The layered architecture

Every request flows down through the layers and back up. Keeping them separate is what keeps a serverless API testable as it grows:

API Gateway β†’ Lambda
  β–Ό handler       parse the request, shape the response (thin)
  β–Ό middleware    inject_services β€” the factory wires dependencies
  β–Ό service       business rules (framework-agnostic, easy to test)
  β–Ό repository    data access β€” the only layer that touches the DB
  β–Ό model         the domain object
  Β· schemas       Pydantic: validate in, serialize out
πŸšͺ

API Gateway

The HTTP front door that routes requests to Lambda.

Ξ»

Lambda (Python)

Runs the handler + layers on demand. Powertools gives clean routing.

πŸ—ƒοΈ

DynamoDB

A managed table for notes (swap for RDS + SQLAlchemy if relational).

πŸ—οΈ

SAM

Every resource in one template.yaml β€” infrastructure as code.

The project structure

Start with the minimum, then let it grow into the shape below. The rule of thumb: one directory per layer, one file that reads the environment, and everything that isn't code β€” infra, CI, per-stage config β€” lives at the root where it's easy to review.

notes-api/
β”œβ”€ template.yaml                       # infra: table, function(s), routes, event bus, IAM
β”œβ”€ samconfig.toml                      # per-stage deploy config (dev / prod)
β”œβ”€ requirements.txt                    # runtime deps (boto3 ships with Lambda)
β”œβ”€ .github/workflows/deploy.yml        # CI/CD: build β†’ sync secrets β†’ sam deploy
β”œβ”€ tests/                              # unit tests β€” services with a fake repository
└─ src/
   β”œβ”€ service_factory.py               # DI: builds services + repositories (lazily)
   β”œβ”€ env_vars.py                      # the ONE place that reads os.environ
   └─ notes/
      β”œβ”€ handlers/
      β”‚  β”œβ”€ api/notes.py               # HTTP routes β€” thin
      β”‚  └─ events/on_note_event.py    # EventBridge handler β€” reacts to events
      └─ application/
         β”œβ”€ middlewares.py             # inject_services (the factory middleware)
         β”œβ”€ schemas.py                 # Pydantic request/response models
         β”œβ”€ services.py                # business logic
         β”œβ”€ repositories.py            # data access (the only layer that touches the DB)
         β”œβ”€ event_bus.py               # publishes domain events to EventBridge
         └─ models.py                  # domain models
PathOwns
template.yamlEvery AWS resource: DynamoDB table, Lambda functions, API routes, the event bus, and least-privilege IAM. The single source of truth for infra.
samconfig.tomlNamed deploy environments ([dev], [prod]) β€” stack name, region, and parameter overrides per stage, so sam deploy --config-env prod just works.
env_vars.pyReads os.environ in one place and hands typed values to the rest of the code β€” no scattered os.getenv calls.
handlers/Entry points. api/ for HTTP (API Gateway), events/ for EventBridge. Both stay thin and delegate to a service.
application/The layers: schemas, services, repositories, the event-bus client, and models. This is where the work lives.
.github/workflows/The pipeline β€” build, push secrets to AWS, and deploy. Covered below.
πŸ“

Grow by adding a folder, not by fattening a file. A second resource (say tags) becomes a sibling package under src/ with its own handlers + application layers, reusing the same factory. Nothing existing has to change.

Step 1

Install the tools

brew install awscli aws-sam-cli    # macOS; see docs for other OSes
aws configure                      # access key + region, e.g. eu-west-1
⚠️

Don't use your root account. Create a scoped IAM user; treat access keys like passwords.

Step 2

Declare the infrastructure

# template.yaml
Transform: AWS::Serverless-2016-10-31
Resources:
  NotesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions: [{ AttributeName: id, AttributeType: S }]
      KeySchema:            [{ AttributeName: id, KeyType: HASH }]
  NotesFn:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: notes.handlers.api.notes.handler
      Runtime: python3.13
      Environment: { Variables: { TABLE: !Ref NotesTable } }
      Policies: [{ DynamoDBCrudPolicy: { TableName: !Ref NotesTable } }]
      Events:
        Api:   { Type: Api, Properties: { Path: /notes, Method: any } }
        ApiId: { Type: Api, Properties: { Path: /notes/{id}, Method: any } }
Step 3

Build the layers, bottom-up

Schemas β€” validate in, serialize out

Pydantic models are the contract at the edges. Bad input never reaches your logic:

# src/notes/application/schemas.py
from pydantic import BaseModel, Field

class CreateNote(BaseModel):
    text: str = Field(min_length=1)

class NoteResponse(BaseModel):
    id: str
    text: str

Repository β€” the only layer that touches data

# src/notes/application/repositories.py
import os, boto3

class NoteRepository:
    def __init__(self, table=None):
        self.table = table or boto3.resource("dynamodb").Table(os.environ["TABLE"])

    def list(self) -> list[dict]:
        return self.table.scan().get("Items", [])

    def create(self, note: dict) -> dict:
        self.table.put_item(Item=note)
        return note

    def delete(self, note_id: str) -> None:
        self.table.delete_item(Key={"id": note_id})

Service β€” the business rules

Services know nothing about HTTP or AWS β€” just the domain. That's what makes them trivial to unit-test:

# src/notes/application/services.py
import uuid
from notes.application.schemas import CreateNote, NoteResponse

class NoteService:
    def __init__(self, repository, logger):
        self.repository = repository
        self.logger = logger

    def list_notes(self) -> list[NoteResponse]:
        return [NoteResponse(**n) for n in self.repository.list()]

    def create_note(self, command: CreateNote) -> NoteResponse:
        note = {"id": str(uuid.uuid4()), "text": command.text}
        self.repository.create(note)
        return NoteResponse(**note)

    def delete_note(self, note_id: str) -> None:
        self.repository.delete(note_id)

The middleware factory β€” dependency injection

Instead of each handler constructing its own services, a factory builds them lazily, and a middleware injects them into every request. Handlers just reach for what they need β€” and tests can swap in a fake repository.

# src/service_factory.py β€” builds services + repositories on demand
from aws_lambda_powertools import Logger
from notes.application.repositories import NoteRepository
from notes.application.services import NoteService

class ServiceFactory:
    def __init__(self, logger: Logger):
        self.logger = logger
        self._note_repository = None

    @property
    def note_repository(self) -> NoteRepository:   # lazy: built once, reused
        if self._note_repository is None:
            self._note_repository = NoteRepository()
        return self._note_repository

    def create_note_service(self) -> NoteService:
        return NoteService(repository=self.note_repository, logger=self.logger)
# src/notes/application/middlewares.py β€” inject deps into the request
from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response
from aws_lambda_powertools.event_handler.middlewares import NextMiddleware
from service_factory import ServiceFactory

logger = Logger(service="NotesAPI")

def inject_services(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
    factory = ServiceFactory(logger=logger)
    app.context.setdefault("services", {})
    app.context["services"]["notes"] = factory.create_note_service()
    return next_middleware(app)

Handler β€” thin routing

The handler wires the middleware chain with app.use(...), validates input with a schema, and delegates to the injected service β€” no business logic, no direct DB calls:

# src/notes/handlers/api/notes.py
from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler import APIGatewayRestResolver, CORSConfig
from aws_lambda_powertools.event_handler.exceptions import BadRequestError
from pydantic import ValidationError
from notes.application.middlewares import inject_services
from notes.application.schemas import CreateNote

app = APIGatewayRestResolver(cors=CORSConfig())
app.use(middlewares=[inject_services])          # + api_error_handler, log_request_response, auth…

@app.get("/notes")
def list_notes():
    return [n.model_dump() for n in app.context["services"]["notes"].list_notes()]

@app.post("/notes")
def create_note():
    try:
        command = CreateNote(**(app.current_event.json_body or {}))
    except ValidationError as e:
        raise BadRequestError(str(e))
    return app.context["services"]["notes"].create_note(command).model_dump(), 201

@app.delete("/notes/<note_id>")
def delete_note(note_id):
    app.context["services"]["notes"].delete_note(note_id)
    return {}, 204

def handler(event, context):
    return app.resolve(event, context)
πŸ§…

Why the ceremony pays off: the handler is swappable, the service is pure logic you can test without AWS, the repository is the one place data access lives, and the factory means adding a dependency (an event bus, an S3 client, a second repository) touches one file. This is the shape of every JunctionNet Lambda API.

Step 4

Test locally, then deploy

sam build
sam local start-api           # http://localhost:3000
sam deploy --guided           # first time; thereafter just: sam deploy

SAM prints your live API Gateway URL. In production, run sam deploy from GitHub Actions on every merge.

The request's User & Organization context

A real API is multi-tenant: every request belongs to a user inside an organization, and one org must never see another's data. You don't parse tokens in the handler β€” the authorizer validates the credential once at the edge and injects the identity, and the repository scopes every query by org.

Authorization: Bearer <jwt>  /  x-api-key
  β–Ό Lambda Authorizer   validate credential, resolve org_id + username (once)
  β–Ό requestContext.authorizer.lambda = { org_id, username }
  β–Ό middleware          read it into the request context
  β–Ό repository          every query filtered by org_id β€” tenant isolation

Resolve identity once, from the authorizer

# src/notes/application/execution_context.py β€” who is calling, resolved once
from dataclasses import dataclass

@dataclass(frozen=True)
class RequestContext:
    org_id: str
    username: str

def from_event(event) -> RequestContext:
    claims = event["requestContext"]["authorizer"]["lambda"]
    return RequestContext(org_id=claims["org_id"], username=claims["username"])

The middleware reads it off the event and puts it on the request alongside the services, so handlers just reach for it:

# middlewares.py β€” inject identity + services
def inject_services(app, next_middleware):
    ctx = from_event(app.current_event.raw_event)      # from the authorizer, never the body
    app.context["ctx"] = ctx
    factory = ServiceFactory(logger=logger, context=ctx)
    app.context.setdefault("services", {})["notes"] = factory.create_note_service()
    return next_middleware(app)

And the repository β€” the only layer that touches data β€” filters by org_id on every call:

# repositories.py β€” every query scoped by org
from boto3.dynamodb.conditions import Key

def list(self, org_id: str) -> list[dict]:
    return self.table.query(
        KeyConditionExpression=Key("org_id").eq(org_id)).get("Items", [])
🏒

Never trust identity from the request body. org_id comes only from the authorizer-verified context β€” a client can put anything in JSON. Scope by org_id in the repository, not the handler, so no route can ever leak another tenant's data.

The database connection

DynamoDB is the easy case: there's no connection to manage β€” every call is an IAM-signed HTTPS request, so the repository just holds a boto3 client and Lambda scaling is a non-issue. The moment you want relational data (Postgres on RDS/Aurora, or Supabase) two things change: you open a real connection, and you have to think about how many.

Reuse the connection across warm invocations

Lambda freezes the container between requests and thaws it for the next one. Build the engine once at module scope (or behind a cache) so warm invocations reuse it instead of reconnecting on every call β€” and pull the credentials from Secrets Manager, never from code:

# src/notes/application/repositories.py β€” relational variant (SQLAlchemy)
import os, json, functools, boto3
from sqlalchemy import create_engine, text

@functools.lru_cache(maxsize=1)          # built once per warm container, then reused
def _engine():
    secret = boto3.client("secretsmanager").get_secret_value(
        SecretId=os.environ["DB_SECRET_ARN"])
    url = json.loads(secret["SecretString"])["connection_string"]
    return create_engine(url, pool_pre_ping=True, pool_size=1, max_overflow=0)

class NoteRepository:
    def list(self) -> list[dict]:
        with _engine().connect() as conn:
            rows = conn.execute(text("SELECT id, text FROM notes"))
            return [dict(r._mapping) for r in rows]
🌊

Mind the connection storm. Lambda can spin up thousands of concurrent containers, each holding its own DB connection β€” enough to exhaust Postgres. Keep pool_size tiny (1–2) and put a pooler in front: RDS Proxy for RDS/Aurora, or Supabase's Supavisor in transaction mode (port 6543). The pooler multiplexes many Lambdas onto a few real connections.

πŸ”Œ

Two connection strings, two jobs. Runtime traffic goes through the transaction pooler (short-lived, high-churn). Schema migrations need a session connection (a direct 5432 URL) β€” run them as a separate CI step before the deploy, so new code never hits an old schema.

Secrets & API tokens: GitHub β†’ SSM / Secrets Manager β†’ SAM

Never commit a token and never paste one into template.yaml. The clean pattern has one source of truth in AWS and a pipeline that wires it in at deploy time. The flow:

GitHub Secrets / Variables
  β”‚  # CI job, on merge to main
  β–Ό
AWS SSM Parameter Store  Β·  AWS Secrets Manager   # source of truth at deploy + runtime
  β”‚  # {{resolve:ssm:…}} / {{resolve:secretsmanager:…}}  or  --parameter-overrides
  β–Ό
SAM template.yaml  β†’  Lambda env vars (injected at deploy)
  β”‚
  β–Ό  # …or fetched at cold start, for secrets that rotate
running Lambda
ValueLives inReaches Lambda by
Non-secret config
table name, API base URL, stage
SSM Parameter Store (String) or a template parameterEnv var, resolved at deploy
Real secret
DB password, 3rd-party API key
Secrets Manager (or SSM SecureString)ARN in env β†’ fetched at cold start; or resolved at deploy
AWS deploy credsGitHub OIDC role (preferred) or GitHub SecretsAssumed by the CI job only β€” never shipped to Lambda

The pipeline: push the value, then deploy

GitHub holds only what CI needs to authenticate. App secrets can be kept in AWS directly, or seeded from GitHub Secrets in an idempotent step so the repo stays the single place a value is set:

# .github/workflows/deploy.yml (excerpt)
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}   # OIDC β€” no long-lived keys
    aws-region: eu-west-1

- name: Sync app secret β†’ SSM / Secrets Manager
  run: |
    aws ssm put-parameter --name "/notes/$STAGE/anthropic_api_key" \
      --value "$ANTHROPIC_API_KEY" --type SecureString --overwrite
  env:
    STAGE: prod
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}   # from GitHub Secrets

- name: Deploy
  run: sam deploy --config-env "$STAGE" --parameter-overrides Stage="$STAGE"

SAM injects it β€” config baked in, secrets by reference

# template.yaml
Parameters:
  Stage: { Type: String, Default: dev }
Resources:
  NotesFn:
    Type: AWS::Serverless::Function
    Properties:
      Environment:
        Variables:
          STAGE: !Ref Stage
          TABLE: !Ref NotesTable
          # non-secret config, resolved from SSM at deploy time:
          API_BASE_URL:  !Sub '{{resolve:ssm:/notes/${Stage}/api_base_url}}'
          # a secret's ARN β€” the VALUE is fetched at cold start (survives rotation):
          DB_SECRET_ARN: !Sub '{{resolve:ssm:/notes/${Stage}/db_secret_arn}}'
      Policies:                                        # least-privilege, scoped to this stage
        - SSMParameterReadPolicy: { ParameterName: !Sub 'notes/${Stage}/*' }
        - AWSSecretsManagerGetSecretValuePolicy:
            SecretId: !Sub '{{resolve:ssm:/notes/${Stage}/db_secret_arn}}'
♻️

Resolve-at-deploy vs fetch-at-runtime. {{resolve:ssm:…}} bakes the value into the env var during the deploy β€” great for config, but a rotated secret won't reach a running function until the template changes and you redeploy. For anything that rotates, store the ARN in the env and read the value at cold start (as the DB example does), so rotation is picked up automatically.

πŸ”‘

Prefer OIDC over stored keys. Give GitHub Actions a role to assume via OIDC instead of long-lived AWS_ACCESS_KEY_ID/SECRET in GitHub Secrets β€” short-lived credentials, nothing to rotate or leak.

The event bus: services that react to each other

Some work shouldn't happen inline with the request β€” indexing a note for search, sending a notification, metering usage. Instead of the service calling those directly (and waiting on them), it publishes a domain event to EventBridge and moves on. Consumers subscribe by pattern and run in their own Lambda β€” the producer never knows they exist.

Publish from the service

# src/notes/application/event_bus.py β€” a thin publisher
import os, json, boto3

class EventBus:
    def __init__(self, client=None):
        self.client = client or boto3.client("events")
        self.bus = os.environ["EVENT_BUS"]

    def publish(self, detail_type: str, detail: dict) -> None:
        self.client.put_events(Entries=[{
            "EventBusName": self.bus,
            "Source": "notes.api",          # {app}.{service} β€” dotted, lowercase
            "DetailType": detail_type,      # "NoteCreated" β€” PascalCase, past tense
            "Detail": json.dumps(detail),
        }])

The factory hands the bus to the service (one more property), and the service fires the event after the write succeeds β€” fire-and-forget, off the request path:

# in services.py
def create_note(self, command: CreateNote) -> NoteResponse:
    note = {"id": str(uuid.uuid4()), "text": command.text}
    self.repository.create(note)
    self.event_bus.publish("NoteCreated", {"id": note["id"]})
    return NoteResponse(**note)

React in a separate handler

# src/notes/handlers/events/on_note_event.py
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import event_source, EventBridgeEvent

logger = Logger(service="NotesEvents")

@event_source(data_class=EventBridgeEvent)
def handler(event: EventBridgeEvent, context):
    if event.detail_type == "NoteCreated":
        logger.info("note created", extra={"id": event.detail["id"]})
        # …update a search index, notify, meter usage β€” the slow work goes here

Wire it in the template

The bus, the producer's permission to publish, and the consumer's subscription rule β€” SAM creates the rule and the invoke permission for you:

# template.yaml
Resources:
  NotesBus:
    Type: AWS::Events::EventBus
    Properties: { Name: !Sub 'notes-${Stage}' }

  NotesFn:                                   # the producer
    Type: AWS::Serverless::Function
    Properties:
      Environment: { Variables: { EVENT_BUS: !Ref NotesBus } }
      Policies:
        - EventBridgePutEventsPolicy: { EventBusName: !Ref NotesBus }

  OnNoteEventFn:                             # the consumer
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: notes.handlers.events.on_note_event.handler
      Runtime: python3.13
      Events:
        NoteEvents:
          Type: EventBridgeRule
          Properties:
            EventBusName: !Ref NotesBus
            Pattern:
              source: [ notes.api ]
              detail-type: [ NoteCreated, NoteDeleted ]
πŸ”

Make handlers idempotent, and watch for loops. EventBridge guarantees at-least-once delivery, so the same event can arrive twice β€” key your side effects on the event id. And if a handler publishes an event its own rule matches, you've built an infinite loop; carry a hop-count in the detail (or narrow the pattern) to break it.

🧩

This is how services stay decoupled. Adding a new reaction β€” email, analytics, a second service β€” is a new consumer with its own rule. The producer's code, and every existing consumer, is untouched.

A CLAUDE.md for the SAM backend

# CLAUDE.md

## What this is
Serverless API on AWS: API Gateway + Lambda (Python 3.13) + DynamoDB, via SAM.
Layered DDD: handlers β†’ services β†’ repositories β†’ models; Pydantic schemas at the edges.

## Structure  # one directory per layer; grow by adding a folder, not fattening a file
src/
  service_factory.py                      # DI: builds services + repositories (lazy)
  env_vars.py                             # the one place that reads os.environ
  notes/
    handlers/{api,events}/                # thin entry points (API Gateway, EventBridge)
    application/{services,schemas}/        # business logic + Pydantic validation
    infrastructure/{models,repositories}/  # ORM + org_id-scoped data access
    domain/                               # value types, state machines, enums
template.yaml  Β·  samconfig.toml  Β·  tests/

## Commands
- Build / Local / Deploy: sam build / sam local start-api / sam deploy --config-env <stage>
- Logs: sam logs -n NotesFn --tail

## Architecture rules
- Handlers are thin: parse, delegate to an injected service, shape the response. No logic, no DB.
- Dependencies come from the ServiceFactory via the inject_services middleware β€”
  handlers never construct services or repositories directly.
- Only repositories touch the database. Only schemas (Pydantic) cross the edges.
- All infra in template.yaml; IAM = least privilege (never "Action": "*").

## Auth & multi-tenancy
- org_id + username come from the authorizer (requestContext), never the request body.
- Scope every query by org_id in the repository β€” no route may leak another tenant's data.

## Secrets & config
- Never commit secrets or paste them in template.yaml. Source of truth is AWS:
  SSM Parameter Store (config) / Secrets Manager (secrets). CI syncs GitHub β†’ AWS.
- Config: resolve via {{resolve:ssm:/notes/${Stage}/...}} into env at deploy.
- Rotating secrets: put the ARN in env, fetch the value at cold start (not resolve).

## Database
- Relational: build the engine once at module scope; small pool; go through a
  pooler (RDS Proxy / Supavisor). Run migrations in CI before the deploy.

## Events (EventBridge)
- source = {platform}.{service}.{module} (dotted, stable); detail-type = {Entity}{PastTenseVerb}.
- Publish from the SERVICE, after the write commits. Off the request path. One event per fact.
- Consumers: narrow pattern; idempotent (key on event id) + DLQ. Bursty work goes
  event β†’ SQS β†’ Lambda (batch + retries). Mirror the bus to OpenSearch for analytics.

## Verify
sam local start-api and curl the routes; check CloudWatch logs after deploy.
Grow

Adding the next service β€” same shape

Every service is the same DDD skeleton, so adding one is copy-the-shape, not invent-a-new-one. A new orders service mirrors the existing layout exactly:

services/orders/                 # a new SAM stack β€” mirrors documents/
β”œβ”€ src/
β”‚  β”œβ”€ handlers/{api,event_bus}/   # thin entry points
β”‚  β”œβ”€ application/{services,schemas}/
β”‚  β”œβ”€ infrastructure/{models,repositories}/   # org_id-scoped
β”‚  β”œβ”€ domain/
β”‚  β”œβ”€ factories.py                # lazy DI
β”‚  └─ env_vars.py
β”œβ”€ template.yaml                  # its own stack + least-privilege IAM
└─ samconfig.toml
🧩

Module or its own stack? A cohesive sub-domain can be another module under one service's src/<module>/; a separately-deployed or separately-scaled concern gets its own services/<name>/ stack. Either way services stay decoupled β€” they talk over EventBridge (Part 7), never by importing each other.

Ask for a feature the way an engineer would

With the layers in place, don't just say "add filtering." State the contract, route each piece to its layer, lean on Pydantic for validation, keep IAM least-privilege, and end with a way to verify:

> Add "list notes by tag with pagination", following the layers:
  Contract: GET /notes?tag=work&limit=20&cursor=<token>
  β†’ { "items": [...], "next_cursor": "..." | null }.
  schemas/ : a ListNotesQuery (Pydantic) β€” tag optional str,
  limit int 1–100 default 20, cursor optional str.
  repositories/ : list_notes(tag, limit, cursor) does the DynamoDB
  query + pagination β€” a Query on a GSI, never a Scan.
  services/ : returns a ListNotesResponse; the handler stays thin
  and uses the injected service.
  Update template.yaml only if a new IAM action is needed, and keep
  it least-privilege. Don't touch the existing routes.
  Show me the diff and a `sam local` curl for each case first.
🧱

Reusable shape: contract β†’ a line per layer β†’ Pydantic validation β†’ IAM & data-access rules β†’ don't break X β†’ verify with sam local. Same recipe for a new resource, an EventBridge handler, or an auth check.

Supercharge Claude Code with the AWS Agent Toolkit

Install the AWS Agent Toolkit: it bundles the AWS MCP Server (Claude Code can query AWS docs, discover skills, and run real AWS calls) plus curated AWS skills:

# in Claude Code
/plugin marketplace add aws/agent-toolkit-for-aws
/plugin install aws-core@agent-toolkit-for-aws
/reload-plugins
> Add a GET /notes/{id} route following the existing layers: a
  repository.get(id), a service.get_note(id) returning NoteResponse,
  and a thin handler that uses the injected service. Keep the IAM
  policy least-privilege. Show me the diff before deploying.
Check yourself

Quiz β€” 20 questions

Answer every question, then submit to see your score and the correct answers.

Building serverless on AWS?

JunctionNet ships production serverless backends on AWS every day β€” Lambda, EventBridge, RDS, Cognito, Terraform. Reach out for help or a team workshop.

βœ‰οΈ Get in touch ← Back to workshops