Engineering reference: the menu cost calculator architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the stamped costing, and the yield model.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- Analytics
- Front-end & mobile
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
mc-prices | S3 put on the invoices prefix | Extracts lines from a structured file, or reads a scan; updates the weighted average price | 180s / 1024 MB |
mc-cost | EventBridge, weekly | Recosts every dish at current prices and yields; writes an immutable costing per dish | 300s / 1024 MB |
mc-report | EventBridge, monthly | Joins costings to sales; decomposes cost changes; computes theoretical against actual usage | 300s / 1024 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
mc-prices-role | s3:GetObject, bedrock:InvokeModel, dynamodb:UpdateItem | The invoices prefix; one model id; ingredients |
mc-cost-role | dynamodb:Query, dynamodb:PutItem | Ingredients; put only on costings |
mc-report-role | dynamodb:Query | Read-only across both tables |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: ingredients
PK ingredient_id S beef_fillet
unit S kg
price_pence N weighted average of recent deliveries
price_latest N most recent invoice, for the step-change flag
trim_yield N 0.68 — measured, with a date
cook_yield N 0.82 — applied only where the recipe says cooked
yield_measured_at S null means it came from a table, not a scale
trim_credit_pence N 0 unless the offcuts are genuinely used
supplier S yields are per supplier, not per ingredient
`yield_measured_at` being null is surfaced on every costing that uses
it, so a book figure is never mistaken for a measurement.
Table: costings
PK dish_id S
SK costed_on S 2026-08-25 — weekly, immutable
recipe_version S v4
lines L [{ingredient, qty, unit, price_used,
yield_used, basis: raw|cooked, cost}]
plate_cost_pence N
menu_price_pence N
gp_pct N
margin_pence N reported next to gp_pct, always
stale BOOL true if any input is over 30 days old
`lines` copies price_used and yield_used in rather than referencing
them. That copy is what makes two costings comparable months apart.
Inbound and outbound
- Structured invoice files are parsed directly. Only scans reach the model, and moving a supplier onto a file removes that cost permanently.
- Prices are a weighted average over recent deliveries, with a flag when the latest invoice diverges enough to suggest a step change.
- Yields are per ingredient per supplier, with the measurement date. A supplier change invalidates the yield.
- Recipes are versioned. A portion change is a new version, so sales and costings both join to the version that was actually in use.
The model call
- One read per scanned invoice. Extracting supplier, date, lines, quantities, units and prices.
- It never estimates a yield. An unmeasured yield is flagged on every costing that depends on it, which is what gets it measured.
- It never suggests a price. Menu pricing needs the room, the competition and what the dish is for, none of which are in this data.
- Unit conversion is code, not a model. Cases to kilos, litres to millilitres: a lookup that must be right rather than probably right.
- The cost page assumes one read per invoice, which falls as suppliers move to files.
Things worth knowing before you build it
- Copy prices and yields into the costing rather than referencing them. A costing that recomputes itself has no history and no attribution.
- Apply cooking loss only where the recipe quantity is a cooked weight. Applying both losses to a raw-weight recipe overstates the cost by around twenty per cent.
- Keep yields per supplier and re-measure when the supplier changes. A specification change is a real cost movement with no price movement attached.
- Report cash margin next to gross profit percentage everywhere. A menu managed on percentage promotes coffee over steak.
- Grey out dishes below about twenty sales in the period. The most dramatic lines on any menu report are always the least-sold ones.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts