AWS Builder Center
Serverless at the Duka: Engineering a Cloud Inventory Platform for African SMEs

Serverless at the Duka: Engineering a Cloud Inventory Platform for African SMEs

A production engineering case study of building a serverless inventory management system for Kenyan small businesses using AWS Lambda, DynamoDB, API Gateway, and Terraform presented live at AWS Community Day Kenya 2026 Pwani Edition, with real architecture decisions, production failures, and lessons learned.

Cloud/DevOps Engineer

The Problem Worth Solving

Across Kenya and much of sub-Saharan Africa, the corner shop the duka is the fundamental unit of retail. Millions of them operate with no point-of-sale system, no inventory software, and no cloud infrastructure. Stock is tracked in notebooks. Restocking decisions are made from memory. Low-stock conditions are discovered by customers, not owners.
This is not a technology access problem. Smartphones are everywhere. Mobile money specifically Mpesa processes billions of shillings daily. The missing piece is not connectivity. It is purpose-built software that costs almost nothing to operate and requires no IT team to maintain.
That gap was the starting point for Smart Inventory Assistant a serverless inventory management platform I built and demonstrated live at AWS Community Day Kenya 2026 in Mombasa.
The talk was titled: "From Manual to Automated: Building Real Small Business Workflows in Kenya with Python and AWS."
This article is not a replay of the talk. It is an engineering case study: the architecture decisions, the infrastructure patterns, the production failures, and the lessons that survive the demo.

Architecture Overview

The system follows a straightforward serverless architecture with a clear separation of concerns at every layer.
Two Lambda functions handle distinct domains: one for product CRUD operations, one for stock movements (restock, sale, movement history). Separating them by domain rather than by HTTP method keeps each function focused, independently deployable, and independently observable in CloudWatch.

Why Serverless for This Use Case

Operational simplicity at the right scale. A small business inventory system serving one shop does not need horizontal scaling, load balancers, or container orchestration. Lambda's concurrency model handles traffic spikes without configuration. The operational burden is essentially zero.
Cost model alignment. At typical SME transaction volumes a few hundred stock movements per day. Lambda and DynamoDB costs are negligible. There is no baseline compute cost. The system costs nothing when it is idle. For a product targeting cost-sensitive markets, this matters.
Maintenance surface. No EC2 instances to patch. No AMIs to manage. No SSH access to secure. The managed service layer absorbs the operational overhead that would otherwise fall on a solo engineer or small team.
Deployment velocity. Lambda functions deploy in seconds. Iterating on business logic during the build phase was fast. When something broke in the stock handler, a fix was deployed and testable in under two minutes.
The honest trade-off: cold starts add latency on first invocation. For an inventory system used by one shop owner rather than thousands of concurrent users, this is an acceptable trade. For a multi-tenant, high-concurrency production system, the architecture would need Provisioned Concurrency and a different DynamoDB access pattern design.

DynamoDB: Single-Table Design and the Access Pattern Decision

The data model deserves detail because it drove everything downstream.
The system stores two entity types: products and stock movements. The access patterns are well-defined:
  • Fetch a product by ID
  • List all products (scan)
  • Fetch all movements for a product, ordered by timestamp descending
  • Write a product update and a movement record atomically after each stock change
Single-table design satisfies all of these without multiple round trips. Both entity types share a partition key PRODUCT#{uuid}, distinguished by sort key: METADATA for the product record, MOVEMENT#{ISO-timestamp} for each movement.
1
2
3
4
5
6
7
PK: PRODUCT#3af45626-0401-42d0-91d8-3cec1cb3b12b
SK: METADATA
→ name, category, price, current_stock, low_stock_threshold

PK: PRODUCT#3af45626-0401-42d0-91d8-3cec1cb3b12b
SK: MOVEMENT#2026-07-04T09:45:00.000Z
→ change_type, quantity, resulting_stock, timestamp
Querying a product and its full movement history is a single Query call with KeyConditionExpression = PK = "PRODUCT#uuid". The sort key ordering gives time-ordered history for free.
The type system lesson — learned the hard way.
boto3's DynamoDB resource will not serialize Python float. A put_item call with a float attribute returns HTTP 200 but writes nothing. The failure is silent. This caused a multi-hour debugging session when stock updates appeared to succeed in the UI but DynamoDB showed no changes.
The fix is a recursive conversion at the storage boundary:
python
1
2
3
4
5
6
7
8
9
10
from decimal import Decimal

def _to_dynamo(obj):
if isinstance(obj, float):
return Decimal(str(obj))
if isinstance(obj, dict):
return {k: _to_dynamo(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_to_dynamo(i) for i in obj]
return obj
Apply this before every put_item. Apply the inverse after every get_item and scan. Do not skip it for any attribute.
ConsistentRead=True is set on all read operations. Without it, DynamoDB's eventually consistent reads occasionally return stale data visible as a stock number that does not update immediately after a sale. For an inventory system where accuracy is the entire value proposition, eventual consistency is unacceptable.

Infrastructure as Code: Terraform Modules and State Design

The entire infrastructure is defined in Terraform, structured as reusable modules:
1
2
3
4
5
6
7
8
9
10
11
12
13
infrastructure/terraform/
├── environments/
│ └── dev/
│ ├── main.tf ← module composition
│ ├── backend.tf ← S3 remote state config
│ ├── variables.tf
│ └── outputs.tf
└── modules/
├── dynamodb/ ← table + lifecycle rules
├── lambda/ ← functions + packaging
├── api_gateway/ ← HTTP API + routes + integrations
├── iam/ ← execution role + policies
└── monitoring/ ← log groups + alarms + SNS
Why modules matter beyond organisation. Each module exposes outputs consumed by other modules. module.dynamodb.table_arn feeds into the IAM policy. module.api_gateway.execution_arn feeds into the Lambda permission resource. The dependency graph is explicit and machine-verifiable by terraform validate.
State management. Remote state lives in S3 with native locking (use_lockfile = true), available in Terraform 1.10+. This eliminates the DynamoDB lock table that was previously required, simplifying the bootstrap process. The S3 bucket was created once via a bootstrap script before any Terraform ran.
The most significant infrastructure lesson from this project:
prevent_destroy = true on production data resources is not optional.
A naming inconsistency between terraform.tfvars and module defaults caused Terraform to plan the destruction of the DynamoDB table. The lifecycle block was the only thing that stopped it:
hcl
1
2
3
4
5
6
7
8
9
10
resource "aws_dynamodb_table" "inventory" {
name = var.table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "PK"
range_key = "SK"

lifecycle {
prevent_destroy = true
}
}
This should be standard practice on any Terraform-managed database resource.
The Lambda packaging problem in CI. The original approach used archive_file data source pointing at the project root. On a developer machine this worked. In CI, it loaded venv/ (400MB) into memory and crashed the provider with fatal error: out of memory. The fix was to move packaging entirely out of Terraform and into the CI workflow as a shell step:
bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Build step in deploy.yml — runs before terraform plan
APP_SRC="$(pwd)/app"
STAGING="$(pwd)/infrastructure/terraform/modules/lambda/staging"
ZIP="$(pwd)/infrastructure/terraform/modules/lambda/lambda_package.zip"

mkdir -p "$STAGING"
cp -r "$APP_SRC/handlers" "$STAGING/handlers"
cp -r "$APP_SRC/frontend/models" "$STAGING/models"
cp -r "$APP_SRC/frontend/services" "$STAGING/services"
cp -r "$APP_SRC/frontend/storage" "$STAGING/storage"
cp "$APP_SRC/frontend/config.py" "$STAGING/config.py"

pip install pydantic boto3 \
--target "$STAGING" \
--platform manylinux2014_x86_64 \
--only-binary=:all: \
--python-version 3.12 \
--implementation cp

cd "$STAGING" && zip -r "$ZIP" . -q
filebase64sha256(zip_path) is evaluated at terraform plan time. The ZIP must exist before plan runs. Building it as a workflow step before init resolves this dependency correctly.
Note the --platform manylinux2014_x86_64 flag. Lambda runs on Amazon Linux. If you build the ZIP on Windows or macOS, native C extensions (like pydantic-core) compile for the wrong platform and fail at runtime with a binary incompatibility error. This flag forces pip to download pre-compiled Linux wheels regardless of the host OS.

AWS Well-Architected Alignment

Operational Excellence

The system is deployed exclusively through CI/CD. No manual console changes reach production. Every change goes through git push → GitHub Actions → terraform apply. Infrastructure changes are reviewed as code before they execute.
CloudWatch log groups have 14-day retention explicitly set in Terraform. Without this, Lambda creates log groups with unlimited retention, accumulating cost indefinitely.
Structured JSON logging from every Lambda invocation enables CloudWatch Insights queries rather than text searches:
json
1
2
3
4
5
6
7
8
{
"level": "INFO",
"message": "Incoming request",
"method": "POST",
"path": "/api/v1/products/{id}/sale",
"request_id": "abc-123",
"function": "smart-inventory-products-dev"
}

Security

IAM policy design follows the principle of minimal surface area. The Lambda execution role has five DynamoDB permissions exactly those used by the application code scoped to a single table ARN. No wildcard actions. No wildcard resources.
An explicit DENY on destructive DynamoDB actions is the most important security decision in the entire stack:
hcl
1
2
3
4
5
6
7
8
9
{
Effect = "Deny"
Action = [
"dynamodb:DeleteTable",
"dynamodb:UpdateTable",
"dynamodb:CreateTable",
]
Resource = "*"
}
Deny always wins in IAM policy evaluation. This block survives any future accidental permission grants. Lambda cannot destroy the database regardless of what other policies are attached.
No credentials are hardcoded anywhere. Lambda authenticates to DynamoDB through its execution role no access keys, no secrets in environment variables for AWS service access. CloudWatch put_metric_data and SNS publish follow the same pattern.
Input validation runs at the Lambda boundary before business logic executes: payload size cap (50KB), string sanitization for injection patterns, path parameter sanitization, Content-Type enforcement on mutation endpoints.

Reliability

DynamoDB with PAY_PER_REQUEST billing mode provides effectively unlimited throughput without capacity planning. Point-in-time recovery is enabled on the table. The prevent_destroy lifecycle block prevents accidental data loss through infrastructure operations.
CloudWatch alarms on error rate, duration (p90), and throttle count provide early warning before user-visible failures compound. Alarm actions target an SNS topic for immediate notification.

Performance Efficiency

HTTP API (v2) was chosen over REST API for API Gateway. HTTP API has lower latency, simpler configuration, and lower cost for Lambda proxy integrations which is the only integration type used here. The additional features of REST API (request/response transformation, usage plans, API keys) are not needed and add overhead without benefit.
DynamoDB's Query operation on the single table returns product metadata and movement history in one round trip. No application-side joins. No N+1 queries.

Cost Optimization

At the transaction volume of a single small business estimated hundreds of operations per day the compute, database, and API costs approach zero. Lambda bills per invocation and duration. DynamoDB bills per read and write unit. API Gateway HTTP API bills per request. There is no baseline cost when the system is idle.
The entire stack costs less than USD 1 per month for a single-shop deployment. This is the economic argument for serverless in cost-sensitive markets.

Sustainability

Serverless architecture inherently improves sustainability relative to reserved compute. Resources are consumed only during active invocations. No EC2 instances run idle overnight. DynamoDB's managed scaling means no over-provisioned capacity sitting unused.

Observability Design

Three layers of observability are in place.
Structured logging from Lambda to CloudWatch Logs. Every request log line includes method, path, request ID, duration, and error detail when applicable. Searchable by field in CloudWatch Insights.
CloudWatch metric alarms on Lambda built-in metrics:
  • Errors > 5 in a 5-minute period
  • Duration p90 > 10000ms
  • Throttles > 10 in a 5-minute period
Custom metrics for business events. When a sale reduces stock to or below the low-stock threshold, Lambda publishes a LowStockAlert metric to the SmartInventory namespace:
python
1
2
3
4
5
6
7
8
9
cloudwatch.put_metric_data(
Namespace="SmartInventory",
MetricData=[{
"MetricName": "LowStockAlert",
"Dimensions": [{"Name": "ProductName", "Value": product.name}],
"Value": 1,
"Unit": "Count"
}]
)
A CloudWatch alarm on this metric fires to an SNS topic with email delivery. The Lambda IAM role includes cloudwatch:PutMetricData permission scoped to the SmartInventory namespace.
The critical operational lesson from this layer: SNS email subscriptions require manual confirmation. If a Terraform deployment recreates an SNS subscription which happens when the topic ARN changes or the email endpoint changes the new subscription starts in PendingConfirmation state. Alerts will be silently dropped until the confirmation link in the AWS notification email is clicked. This is not obvious from Terraform output and is easy to miss under deadline pressure.

The API Design Decision Worth Explaining

The most deliberate API design choice in the project is the separation of restock and sale into distinct endpoints rather than a single stock-update endpoint.
A generic PATCH /products/{id}/stock with a signed delta (+10, -3) is technically equivalent. It is also architecturally inferior for several reasons:
Intent is lost. POST /products/{id}/sale is self-documenting. A sale and a restock are different business events with different operational meaning. Collapsing them into one endpoint hides that distinction.
Authorization becomes harder. A future requirement "only managers can approve restocks, but any staff member can record a sale" is trivially implementable with separate endpoints and an authorizer. With a single endpoint, the business logic must inspect the payload to determine which permission to check.
Auditability is cleaner. Every CloudWatch log entry records either a sale or a restock by endpoint path. No parsing required to determine what happened.
Validation differs. Restock quantity is always positive. Sale quantity must not exceed current stock. Keeping these as separate operations means each handler validates exactly what it needs to validate.

Challenges and Failures Worth Documenting

Terraform State and CI/CD Divergence

The first terraform apply ran against the S3 remote backend but only partially completed. Half the resources were provisioned successfully while the rest were not applied, for reasons that were not immediately clear. This left the state file in a partially written condition: some resources existed in AWS and were recorded in remote state, others existed in AWS but were missing from state entirely.
When the GitHub Actions CI/CD pipeline subsequently ran, it read the incomplete state file, saw the missing resources as unmanaged, and attempted to create them from scratch hitting ResourceInUseException on DynamoDB and EntityAlreadyExists on IAM for resources that were already physically present in the AWS account but not tracked in state.
Recovery required terraform import for every resource that existed in AWS but was absent from the state file:
1
2
3
4
terraform import module.dynamodb.aws_dynamodb_table.inventory smart-inventory-dev
terraform import module.iam.aws_iam_role.lambda_execution smart-inventory-lambda-role-dev
terraform import module.iam.aws_iam_role_policy.lambda_cloudwatch \
smart-inventory-lambda-role-dev:smart-inventory-cloudwatch-policy-dev
The lesson: a partial apply is harder to recover from than a clean failure. When Terraform exits mid-run, the state file reflects only what completed not what was intended. Before running CI/CD against any remote state backend, verify that the state is complete and consistent with what actually exists in AWS. Use terraform plan to confirm zero drift before handing control to the pipeline.

HTTP API v2 Event Format

API Gateway HTTP API sends a different event schema to Lambda than REST API:
1
2
3
4
Field REST API (v1) HTTP API (v2)
────────────────────────────────────────────────────────────────────────────────
HTTP method event["httpMethod"] event["requestContext"]["http"]["method"]
Request path event["path"] event["rawPath"]
Lambda handlers written against v1 fail silently against HTTP API because the method and path fields are missing. The fix is to check both:
python
1
2
3
4
5
method = (
event.get("httpMethod") or
event.get("requestContext", {}).get("http", {}).get("method", "")
)
path = event.get("path") or event.get("rawPath", "")
This is not documented prominently in the API Gateway migration guide and is a common source of confusion when switching from REST API to HTTP API.

Engineering Lessons

Explicit over implicit at every boundary. The float-to-Decimal conversion, the v1/v2 event format fallback, the ConsistentRead flag all of these are cases where the safe behavior is not the default. Explicit handling at boundaries prevents a class of bugs that are difficult to reproduce and expensive to debug.
IaC changes infrastructure intent permanently. A variable rename in variables.tf changed the names of every resource Terraform managed. Without prevent_destroy, the pipeline would have deleted the production database. Treat IaC diffs with the same scrutiny as schema migrations.
Structured logging is not optional in serverless. When a Lambda function fails, the only visibility into what happened is CloudWatch logs. Unstructured logs require grep. Structured JSON logs support Insights queries, metric filters, and programmatic analysis. The cost of structured logging is negligible. The cost of not having it during an incident is high.
SNS manual steps break automation. Any infrastructure component that requires a human action to become operational like confirming an email subscription must be explicitly documented and verified before it is needed. Automation that silently fails because of a pending confirmation is worse than no automation.
The storage abstraction was worth the upfront design cost. Defining a clean storage interface from the beginning meant swapping JSON → DynamoDB → API Gateway required changing one file and one environment variable. The 23 tests never changed. The business logic never changed. Portable abstractions are the most valuable engineering investment in a system that will evolve.

What Comes Next

The current architecture provides a clean foundation for the features that matter most in the target market.
Mpesa integration is the highest-priority enhancement. Recording a sale and initiating payment in the same action would bring the system into real retail workflows. Mpesa API credentials would be stored in AWS Secrets Manager and retrieved at Lambda startup.
Amazon Bedrock for inventory forecasting. The stock movement history stored in DynamoDB is training data for demand prediction. An EventBridge-triggered Lambda could periodically query movement history and invoke a Bedrock model to predict restock timing.
Amazon Cognito for multi-tenancy. Adding authentication enables the same system to serve multiple shops with data isolation. API Gateway's JWT authorizer integrates with Cognito user pools without changes to Lambda business logic.
Amazon SQS for resilient stock updates. High-volume scenarios a busy market day could benefit from decoupling the write path. A sale event published to SQS and processed asynchronously by a separate Lambda provides better durability than synchronous DynamoDB writes.
WhatsApp bot integration using Twilio or a WhatsApp Business API provider would make stock recording accessible without opening a browser. A shop owner sending "sold 3 unga" via WhatsApp and having it processed by Lambda and written to DynamoDB is a genuinely different interaction model.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article