DEV Community

Cover image for Designing a Production Serverless API: Beyond API Gateway + Lambda
Maz I
Maz I

Posted on

Designing a Production Serverless API: Beyond API Gateway + Lambda

Designing a Production Serverless API: Beyond API Gateway + Lambda

A serverless API can begin with a diagram that looks almost too easy:

Client
   ↓
API Gateway
   ↓
Lambda
   ↓
DynamoDB
Enter fullscreen mode Exit fullscreen mode

And for many applications, this is a perfectly reasonable starting point.

But now imagine we are building an order API.

Traffic is unpredictable. Most of the day we receive only a few requests, but during promotions thousands of customers can arrive within a short period.

A customer sends:

POST /orders
Enter fullscreen mode Exit fullscreen mode

with something like:

{
  "customerId": "C101",
  "items": [
    {
      "productId": "P10",
      "quantity": 2
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Our requirements initially sound straightforward:

  1. Create the order.
  2. Check inventory.
  3. Take payment.
  4. Send a confirmation email.

We could put everything inside one Lambda:

async function createOrder(order) {
  validate(order);

  await saveOrder(order);
  await reserveInventory(order);
  await chargePayment(order);
  await sendEmail(order);

  return {
    success: true
  };
}
Enter fullscreen mode Exit fullscreen mode

It works.

Until it doesn't.

This article isn't about memorizing AWS services.

Instead, we'll start with that simple implementation and make the requirements progressively harder.

Every time the design breaks, we'll ask:

What problem do we need to solve next?

That is how our simple Lambda gradually becomes a production serverless architecture.

Why serverless in the first place?

Our traffic is unpredictable.

Running servers continuously for occasional spikes could mean maintaining capacity that sits mostly unused.

Lambda is attractive because AWS manages the underlying execution infrastructure and can increase concurrent execution as demand grows, within the applicable scaling and account limits.

So our first version is:

Customer
   ↓
API Gateway
   ↓
CreateOrder Lambda
   ↓
DynamoDB
Enter fullscreen mode Exit fullscreen mode

DynamoDB also fits the serverless model well for workloads where its access-pattern-oriented data model makes sense.

But selecting serverless doesn't remove architecture.

We are about to discover why.

Problem #1: One Lambda is doing too much

Consider this sequence:

Save order        ✅
Reserve inventory ✅
Charge payment    ✅
Send email        ❌ timeout
Enter fullscreen mode Exit fullscreen mode

Our Lambda throws an error.

From the client's perspective, the request failed.

So the client retries.

What should happen?

Should we:

  • create another order?
  • reserve inventory again?
  • charge the customer again?
  • attempt the email again?

We have mixed operations with very different failure characteristics inside one request.

Sending an email should not determine whether an already successful payment is considered successful.

So our first architectural improvement is decoupling.

Email doesn't belong in the critical request path

After payment succeeds, we don't need the customer to wait while an email provider processes a message.

Instead:

Payment succeeds
      ↓
     SQS
      ↓
Email Lambda
      ↓
Email provider
Enter fullscreen mode Exit fullscreen mode

Now the payment workflow can finish while email processing happens independently.

Amazon SQS acts as a buffer between the producer and consumer.

This gives us an important principle:

A queue does not make the consumer faster. It allows producers and consumers to operate at different rates.

It also isolates failures.

If the email provider is temporarily unavailable, that does not need to break order creation.

But asynchronous systems introduce a new issue.

Lambda processing from SQS can result in messages being processed more than once, so AWS recommends making processing idempotent.

That brings us to one of the most important concepts in serverless systems.

Problem #2: Retries can duplicate business operations

Imagine the customer sends:

POST /orders
Enter fullscreen mode Exit fullscreen mode

The server creates:

ORD-9001
Enter fullscreen mode Exit fullscreen mode

but the response is lost because of a network timeout.

The customer retries.

Without protection, we might create:

ORD-9002
Enter fullscreen mode Exit fullscreen mode

for exactly the same checkout.

Worse, the same problem can happen during payment.

Payment provider charges €100  ✅
             ↓
Lambda loses connection        ❌
             ↓
Lambda retries
             ↓
Charge another €100?           😬
Enter fullscreen mode Exit fullscreen mode

A normal database rollback cannot solve this.

The payment provider exists outside our database transaction.

Idempotency

An operation is idempotent when repeating the same logical request does not create an additional side effect.

For order creation, the client might send:

Idempotency-Key: checkout-abc-123
Enter fullscreen mode Exit fullscreen mode

On the first request:

checkout-abc-123
      ↓
Create ORD-9001
Enter fullscreen mode Exit fullscreen mode

If that exact request is retried:

checkout-abc-123
      ↓
Already processed
      ↓
Return ORD-9001
Enter fullscreen mode Exit fullscreen mode

Not:

Create ORD-9002
Enter fullscreen mode Exit fullscreen mode

For payment, we want the same concept.

payment idempotency key = ORD-9001
Enter fullscreen mode Exit fullscreen mode

If a network failure causes us to retry, the retry represents:

“Give me the result of the same payment operation.”

not:

“Perform a brand-new payment.”

This gives us a useful mental rule:

Retry makes systems resilient. Idempotency makes retries safe.

Problem #3: We now have a workflow

Our order process has grown:

Create order
     ↓
Check inventory
     ↓
Reserve inventory
     ↓
Process payment
     ↓
Update order
     ↓
Queue email
Enter fullscreen mode Exit fullscreen mode

We could manually call Lambda A from Lambda B, Lambda B from Lambda C and keep adding flags to DynamoDB.

But now we have questions such as:

  • What if payment times out?
  • What if the card is declined?
  • Which errors should retry?
  • How long should we wait?
  • What happens after the last retry?
  • How do we know which step the order is currently in?

At this point we are no longer dealing with a simple chain of functions.

We are dealing with a workflow.

That is where AWS Step Functions becomes useful.

Step Functions lets us model workflows as state machines and orchestrate distributed applications and microservices.

Our order process can now be represented explicitly:

Create Order
     ↓
Reserve Inventory
     ↓
Process Payment
    /     \
   /       \
Success   Failure
  ↓          ↓
Update     Handle
Order      failure
  ↓
Queue Email
  ↓
Complete
Enter fullscreen mode Exit fullscreen mode

Step Functions without the mystery

A few states are enough to understand most of our example.

Task

Do something.

ReserveInventory
ProcessPayment
UpdateOrder
Enter fullscreen mode Exit fullscreen mode

Choice

Make a decision.

             Payment result
               /        \
              /          \
         SUCCESS        DECLINED
            ↓              ↓
        Continue       Failure path
Enter fullscreen mode Exit fullscreen mode

A Choice state adds conditional branching to a Step Functions workflow.

Retry

Try a failed operation again.

But only when retrying makes sense.

Catch

If the operation cannot recover, route execution to another state.

Succeed / Fail

End the workflow intentionally as successful or failed.

The important part is not memorizing state names.

It's understanding that business flow is now visible outside our Lambda implementation.

Technical failure is not business failure

Consider payment.

A provider timeout might mean:

temporary network problem
Enter fullscreen mode Exit fullscreen mode

That is potentially retryable.

But:

CARD_DECLINED
Enter fullscreen mode Exit fullscreen mode

is not a temporary infrastructure failure.

Retrying the same declined card three times probably makes no sense.

So:

Payment
   |
   +-- TIMEOUT
   |      ↓
   |    Retry
   |
   +-- CARD_DECLINED
          ↓
       Do not retry
          ↓
       Ask customer
       for another method
Enter fullscreen mode Exit fullscreen mode

Step Functions supports error-specific retry and catch behavior.

This gives us another useful distinction:

Temporary technical failure
→ Retry

Valid negative business result
→ Business workflow path
Enter fullscreen mode Exit fullscreen mode

Not every undesirable result should become an exception.

So what is a DLQ?

Retry/Catch and a Dead-Letter Queue solve related but different problems.

Imagine SQS contains a message that repeatedly fails:

message
  ↓
attempt 1 ❌
attempt 2 ❌
attempt 3 ❌
attempt 4 ❌
Enter fullscreen mode Exit fullscreen mode

Instead of allowing it to continually interfere with normal processing, it can eventually be moved to a dead-letter queue for investigation or later redrive.

So remember:

Retry
→ Try this operation again.

Catch
→ The operation did not recover.
  Which workflow path should run?

DLQ
→ This queued message repeatedly failed.
  Move it away from normal processing.
Enter fullscreen mode Exit fullscreen mode

They are not interchangeable concepts.

Problem #4: We don't have one giant rollback anymore

Now suppose the workflow is:

Create Order       ✅
Reserve Inventory  ✅
Charge Payment     ❌
Enter fullscreen mode Exit fullscreen mode

If this were one relational database transaction, we might think:

ROLLBACK;
Enter fullscreen mode Exit fullscreen mode

But inventory, payment and shipping may be completely separate services.

There is no global database transaction covering everything.

So if payment permanently fails, we must undo the earlier successful business action:

Release inventory
Enter fullscreen mode Exit fullscreen mode

This leads us to the Saga pattern.

Saga in simple language

A Saga breaks a distributed transaction into smaller local transactions.

For important forward actions, we define an appropriate compensating action.

For example:

Forward action          Compensation

Create order        →   Cancel order
Reserve inventory   →   Release inventory
Charge payment      →   Refund payment
Create shipment     →   Cancel shipment
Enter fullscreen mode Exit fullscreen mode

Suppose:

Create Order       ✅
Reserve Inventory  ✅
Charge Payment     ✅
Create Shipment    ❌
Enter fullscreen mode Exit fullscreen mode

We might compensate:

Shipment failed
      ↓
Refund payment
      ↓
Release inventory
      ↓
Cancel order
Enter fullscreen mode Exit fullscreen mode

A compensation isn't necessarily a magical reversal of history.

A refund, for example, is another business operation that compensates for a successful charge.

That distinction matters.

There are two common Saga coordination styles.

Choreography

Services react to events:

OrderCreated
     ↓
Inventory service

InventoryReserved
     ↓
Payment service

PaymentFailed
     ↓
Inventory service releases stock
Enter fullscreen mode Exit fullscreen mode

Orchestration

A central workflow explicitly coordinates the steps:

            Step Functions
                 |
        +--------+--------+
        |        |        |
     Order   Inventory  Payment
Enter fullscreen mode Exit fullscreen mode

Our example is a natural fit for orchestration because the workflow has clear business states, retries and compensations.

Problem #5: Some work can happen in parallel

Payment has succeeded.

Now we need to:

Send confirmation email
Update loyalty points
Notify warehouse
Enter fullscreen mode Exit fullscreen mode

These operations do not necessarily depend on one another.

Running:

Email
  ↓
Loyalty
  ↓
Warehouse
Enter fullscreen mode Exit fullscreen mode

would unnecessarily serialize them.

Step Functions has a Parallel state for independent branches:

              Payment Success
                    ↓
                 Parallel
              /      |       \
             ↓       ↓        ↓
          Email   Loyalty   Warehouse
              \      |       /
               \     |      /
                 complete
Enter fullscreen mode Exit fullscreen mode

A Parallel state starts its branches concurrently and waits for all branches to terminate before moving on.

But this introduces an architectural choice.

If the main workflow must know that all three operations completed, Parallel makes sense.

If these are independent side effects that the main workflow doesn't need to wait for, event-driven fan-out may be cleaner:

            PaymentSucceeded
                  ↓
              Event/Event Bus
             /       |       \
            ↓        ↓        ↓
         Email    Loyalty   Warehouse
Enter fullscreen mode Exit fullscreen mode

So:

Parallel state means “do these things concurrently and my workflow cares about their completion.”

while event choreography often means:

“These consumers can react independently and the producer doesn't need to coordinate them.”

Map: parallel processing for collections

Parallel is useful when the branches perform different jobs.

What if an order contains 500 items and we need to check every item?

That's the same operation repeated over a collection.

A Step Functions Map state fits that problem.

500 order items
       ↓
      Map
       ↓
Check inventory for each item
Enter fullscreen mode Exit fullscreen mode

For large workloads, Step Functions also provides Distributed Map; AWS recommends that mode for scenarios such as concurrency above 40 iterations or very large execution histories/datasets.

But this raises another question.

Should all 500 checks execute simultaneously?

Maybe not.

Problem #6: Scaling can become dangerous

This is one of the most important serverless lessons.

Imagine:

500 inventory checks
Enter fullscreen mode Exit fullscreen mode

and the inventory dependency can safely handle:

10 concurrent operations
Enter fullscreen mode Exit fullscreen mode

Running all 500 at once would simply move the bottleneck downstream.

The exact same principle appears in ordinary Node.js:

await Promise.all(hugeArray.map(processItem));
Enter fullscreen mode Exit fullscreen mode

can create too much concurrency.

Different technology, same engineering rule:

Parallelism improves throughput until the dependency becomes the bottleneck.

Now apply that to Lambda itself.

Suppose our API normally receives:

20 requests/second
Enter fullscreen mode Exit fullscreen mode

but a promotion suddenly sends thousands.

Lambda can scale concurrency.

That sounds excellent until every execution calls a payment provider that safely supports only 100 requests per second.

Thousands of requests
        ↓
      Lambda
  scales aggressively
        ↓
Payment Provider
     🔥🔥🔥
Enter fullscreen mode Exit fullscreen mode

The Lambda tier can scale faster than the dependency behind it.

Reserved concurrency

Lambda supports reserved concurrency, which can reserve concurrency for a function while also placing an upper bound on that function's concurrency. AWS specifically notes it can help prevent overwhelming downstream resources such as database connections.

Conceptually:

Payment Lambda
maximum concurrency = 100
          ↓
Payment Provider
Enter fullscreen mode Exit fullscreen mode

Now scaling has a guardrail.

But simply throttling thousands of requests isn't always a good customer experience.

For asynchronous workloads, SQS can absorb the burst:

5,000 jobs
     ↓
    SQS
====================
 jobs waiting safely
====================
     ↓
Lambda consumers
controlled throughput
     ↓
Dependency
Enter fullscreen mode Exit fullscreen mode

SQS/Lambda event source mappings also support concurrency controls.

Again:

Automatic scaling is not automatically safe architecture.

Always ask:

Can Lambda scale?

        ↓ yes

Can everything behind Lambda
scale at the same rate?
Enter fullscreen mode Exit fullscreen mode

Cold starts and provisioned concurrency

When Lambda needs a new execution environment, initialization occurs before the handler processes the request.

That extra initialization latency is commonly called a cold start.

A simplified comparison:

Warm

Request
  ↓
Existing environment
  ↓
handler()
Enter fullscreen mode Exit fullscreen mode

versus:

Cold

Request
  ↓
Initialize environment
  ↓
Load runtime/code/dependencies
  ↓
handler()
Enter fullscreen mode Exit fullscreen mode

This is also why reusable clients are often initialized outside the handler when appropriate:

const client = createClient();

export const handler = async (event) => {
  // reuse client
};
Enter fullscreen mode Exit fullscreen mode

For latency-sensitive workloads, Lambda also offers provisioned concurrency, which keeps execution environments initialized ahead of demand.

Don't confuse the two concepts:

Reserved concurrency
→ controls/reserves concurrency capacity

Provisioned concurrency
→ keeps execution environments initialized
  for predictable startup latency
Enter fullscreen mode Exit fullscreen mode

Problem #7: DynamoDB scales, but our key design still matters

A serverless database does not remove data-modeling decisions.

DynamoDB uses partition-key values to determine how items are distributed internally. Items sharing a partition-key value are grouped together, and when a sort key exists, they are ordered by sort-key value.

Suppose we choose:

PK = merchantId
Enter fullscreen mode Exit fullscreen mode

Usually traffic is distributed:

Merchant A → moderate traffic
Merchant B → moderate traffic
Merchant C → moderate traffic
Enter fullscreen mode Exit fullscreen mode

But during a huge campaign:

Merchant A → enormous traffic 🔥
Merchant B → small
Merchant C → small
Enter fullscreen mode Exit fullscreen mode

Now one partition-key value becomes disproportionately busy.

AWS recommends designing partition keys with many distinct values and reasonably uniform activity.

This is why DynamoDB design begins with access patterns.

Partition key and sort key, simplified

A useful mental model:

Partition Key
→ Which logical group does this item belong to?

Sort Key
→ How do I identify/order items inside that group?
Enter fullscreen mode Exit fullscreen mode

For example:

PK = MERCHANT#M123

SK = ORDER#2026-08-10#9001
SK = ORDER#2026-08-11#9002
SK = ORDER#2026-08-12#9003
Enter fullscreen mode Exit fullscreen mode

Now:

PK = MERCHANT#M123
Enter fullscreen mode Exit fullscreen mode

can retrieve that merchant's order collection.

The timestamp in the sort key can help support chronological/range-oriented access.

GSI: another access path

Suppose our base table is optimized for:

Get an order by orderId
Enter fullscreen mode Exit fullscreen mode

but another requirement is:

Get orders by merchant and date
Enter fullscreen mode Exit fullscreen mode

A Global Secondary Index (GSI) can use a different partition and sort key from the base table.

Conceptually:

Base table

PK = ORDER#9001
Enter fullscreen mode Exit fullscreen mode

while:

GSI

PK = MERCHANT#M123
SK = 2026-08-10#ORDER#9001
Enter fullscreen mode Exit fullscreen mode

Same underlying item.

Different access path.

The key lesson is:

DynamoDB schema design is strongly driven by how the application needs to retrieve data.

That is a different mental model from designing relational tables first and later adding indexes to optimize queries.

Eventual consistency: correct doesn't always mean immediately visible everywhere

Suppose payment succeeds:

ORDER#9001
status = PAID
Enter fullscreen mode Exit fullscreen mode

Immediately afterward, another component reads through a GSI.

Could it briefly observe older data?

Yes.

DynamoDB table/LSI reads can use eventual or strong consistency, while GSI reads are eventually consistent.

Simplified:

Write:
status = PAID
      ↓
immediate eventual read
      ↓
possibly PENDING briefly
      ↓
later read
      ↓
PAID
Enter fullscreen mode Exit fullscreen mode

This doesn't mean the write disappeared.

It reflects the consistency guarantee of that read path.

For an immediately authoritative order confirmation, we may prefer reading the exact order from the base table using the appropriate consistency requirement instead of assuming every secondary view has already converged.

Another distributed-systems lesson:

Correct state and immediate visibility of that state everywhere are different guarantees.

Problem #8: Every Lambda should not have permission to everything

Our architecture now contains several functions:

CreateOrder Lambda
Inventory Lambda
Payment Lambda
Email Lambda
Enter fullscreen mode Exit fullscreen mode

The easiest IAM policy would be:

{
  "Effect": "Allow",
  "Action": "*",
  "Resource": "*"
}
Enter fullscreen mode Exit fullscreen mode

It would also be a terrible production boundary.

A better architecture uses least privilege.

CreateOrder Lambda
→ write required order data
→ start the workflow

Payment Lambda
→ read/update payment state
→ access payment secret

Email Lambda
→ consume email messages
→ access email secret
Enter fullscreen mode Exit fullscreen mode

Lambda uses execution roles to determine what AWS resources a function can access.

Each component should receive the permissions required for its responsibility, not unrestricted authority over the whole application.

For example:

Email Lambda

CAN:
✓ consume email queue
✓ read email-provider credentials

CANNOT:
✗ modify payment records
✗ read payment secrets
✗ delete orders
Enter fullscreen mode Exit fullscreen mode

That reduces the blast radius if one component is compromised.

Secrets are not configuration strings in Git

This should never happen:

const PAYMENT_API_KEY = "live-secret-key";
Enter fullscreen mode Exit fullscreen mode

inside source control.

Sensitive credentials can be stored in AWS Secrets Manager and accessed only by functions whose IAM role allows it.

Payment Lambda
      ↓
Secrets Manager
      ↓
payment-provider credentials
Enter fullscreen mode Exit fullscreen mode

Meanwhile:

Email Lambda
Enter fullscreen mode Exit fullscreen mode

does not need permission to read those credentials.

Authorization boundaries matter just as much as network/application boundaries.

And don't leak the secret through logs

This:

console.log({
  cardNumber,
  cvv,
  paymentToken
});
Enter fullscreen mode Exit fullscreen mode

creates a new security problem.

Prefer operational context:

{
  "orderId": "ORD-9001",
  "paymentAttemptId": "PAY-72",
  "status": "FAILED",
  "errorType": "PROVIDER_TIMEOUT"
}
Enter fullscreen mode Exit fullscreen mode

which takes us to our final production concern.

Problem #9: Order 9001 failed. Where?

Our order can now cross:

API Gateway
   ↓
Lambda
   ↓
DynamoDB
   ↓
Step Functions
   ↓
Inventory Lambda
   ↓
Payment Lambda
   ↓
SQS
   ↓
Email Lambda
Enter fullscreen mode Exit fullscreen mode

A customer says:

“My order failed.”

Which component failed?

Without observability, debugging a distributed system becomes archaeology.

Logs: what happened?

Instead of:

console.log("payment failed");
Enter fullscreen mode Exit fullscreen mode

prefer structured logs:

{
  "correlationId": "REQ-ABC-123",
  "orderId": "ORD-9001",
  "service": "payment",
  "status": "FAILED",
  "errorType": "PROVIDER_TIMEOUT"
}
Enter fullscreen mode Exit fullscreen mode

AWS Powertools for Lambda includes utilities for structured logging, tracing and metrics.

Correlation IDs: connect the story

Carry an identifier across components:

REQ-ABC-123
     |
API Gateway
     |
CreateOrder
     |
Step Functions
     |
Payment
     |
Email
Enter fullscreen mode Exit fullscreen mode

Now logs across several services can be correlated to the same business operation.

Metrics: is the system healthy?

Useful metrics might include:

Lambda error rate
Lambda duration
Lambda throttles
Concurrent executions

Payment failure rate

SQS queue depth
Oldest message age

Failed Step Functions executions
Workflow duration

Orders stuck in PAYMENT_PENDING
Enter fullscreen mode Exit fullscreen mode

Notice the last one.

Observability isn't only:

technical metrics
Enter fullscreen mode Exit fullscreen mode

It also needs:

business metrics
Enter fullscreen mode Exit fullscreen mode

Your infrastructure could technically be healthy while thousands of orders remain stuck in an invalid business state.

Alarms: when should humans care?

Dashboards are useful.

But humans shouldn't have to stare at them continuously.

Examples:

Payment failures suddenly > normal threshold

SQS oldest message > 5 minutes

Lambda throttling increasing

Workflow failures spike
Enter fullscreen mode Exit fullscreen mode

Those signals should trigger alarms and operational action.

Step Functions execution history: observe the business workflow

One advantage of explicit orchestration is that we can inspect a workflow execution:

CreateOrder        ✅
ReserveInventory   ✅
ProcessPayment     ❌
Retry              ❌
Retry              ✅
UpdateOrder        ✅
QueueEmail         ✅
Enter fullscreen mode Exit fullscreen mode

Instead of reconstructing the entire process from Lambda-to-Lambda calls, we can see the state-machine execution itself.

Traces: where did the request spend time?

Suppose checkout completes but takes eight seconds.

Logs tell us that everything executed.

Metrics tell us latency increased.

Tracing helps answer:

API Gateway       40 ms
CreateOrder       90 ms
DynamoDB          12 ms
Inventory        140 ms
Payment         6.8 sec  ← bottleneck
Enter fullscreen mode Exit fullscreen mode

Lambda integrates with AWS X-Ray, which can produce service maps and searchable traces for diagnosing errors and latency bottlenecks.

So a useful observability mental model is:

Logs
→ What happened?

Metrics
→ Is the system healthy overall?

Traces
→ Where did this request spend its time?
Enter fullscreen mode Exit fullscreen mode

The architecture we ended up with

We started here:

API Gateway
    ↓
Lambda
    ↓
DynamoDB
Enter fullscreen mode Exit fullscreen mode

After introducing real production requirements, we arrived at something closer to:

                         Client
                           |
                           v
                     API Gateway
                           |
                           v
                  CreateOrder Lambda
                           |
                           v
                       DynamoDB
                           |
                           v
                    Step Functions
                    /      |      \
                   /       |       \
                  v        v        v
             Inventory   Payment   Other Tasks
                 |          |
                 |      Retry / Catch
                 |          |
                 +---- Saga compensation
                            |
                        Success
                            |
                            v
                           SQS
                            |
                            v
                       Email Lambda
Enter fullscreen mode Exit fullscreen mode

Surrounding that application:

IAM
→ least-privilege permissions

Secrets Manager
→ credentials

CloudWatch
→ logs, metrics, alarms

X-Ray / tracing
→ request path and latency

Correlation IDs
→ connect one order across components
Enter fullscreen mode Exit fullscreen mode

The interesting part is that we didn't start by saying:

“Let's use API Gateway, Lambda, DynamoDB, SQS, Step Functions and ten other AWS services.”

Every component appeared because a requirement exposed a limitation in the simpler design.

We needed asynchronous side effects.

→ SQS.

Retries introduced duplicate operations.

→ Idempotency.

The process became a business workflow.

→ Step Functions.

Distributed operations couldn't share one rollback.

→ Saga and compensation.

Independent work didn't need serial execution.

→ Parallel and Map.

Automatic scaling threatened downstream systems.

→ Concurrency controls and queues.

DynamoDB needed multiple efficient read patterns.

→ PK/SK/GSI design.

Distributed reads did not always become visible simultaneously.

→ Consistency awareness.

More components increased security exposure.

→ Least privilege and secrets management.

More components made failures harder to diagnose.

→ Observability.

The serverless lesson I would keep

Serverless is often introduced as:

“You don't manage servers.”

That's true, but it is only the beginning.

You still have to reason about:

failure
retries
duplicates
idempotency
concurrency
downstream capacity
distributed transactions
compensation
data access patterns
consistency
permissions
security
observability
Enter fullscreen mode Exit fullscreen mode

AWS can manage servers for us.

AWS cannot decide our business guarantees for us.

And perhaps the most useful architectural question throughout this entire example was not:

Which AWS service should I use?

It was:

What problem does my current simple design fail to solve next?

That question is what turns a collection of serverless services into an architecture.

Top comments (0)