Engineering reference: the address verifier 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 latency budget, and the failure behaviour that matters most.
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
- Networking
- 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 |
|---|---|---|---|
av-check | Function URL | Normalise, cache lookup, reference call, classify; hard 800ms budget | 5s / 512 MB |
av-sweep | EventBridge nightly | Re-checks stored addresses by recency, at a capped rate | 300s / 512 MB |
av-report | EventBridge monthly | Decline patterns, failed delivery correlation, the summary | 30s / 512 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 |
|---|---|---|
av-check-role | dynamodb:GetItem/PutItem, secretsmanager:GetSecretValue | The cache table; the reference API credential only |
av-sweep-role | dynamodb:Query/UpdateItem | Cache and addresses |
av-report-role | dynamodb:Query, ses:SendEmail | Addresses, read; one verified identity |
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: cache
PK normalised S 14 chestnut road|ashford|tn24 8ql
outcome S verified | formatting | near_match | not_found
canonical M the reference file’s own form, if matched
suggestion M the near match, if there was exactly one
looked_up_at S 2026-08-06T10:14:00Z
ttl N epoch, +12 months
The key is the NORMALISED address, not what was typed. Keying on the raw
string drops the hit rate from about 70% to near zero and pays for the
same lookup repeatedly.
Table: addresses
PK address_id S the order or customer address identifier
typed M exactly as the customer entered it
stored M what was saved, after any accepted suggestion
outcome S verified | formatting | near_match | not_found | unchecked
suggestion_shown M what was offered, or null
suggestion_taken BOOL or null
carrier_verdict S what the carrier said at booking, if known
delivered_ok N how many successful deliveries here
permanent BOOL true once delivered twice while unverified
last_checked S drives the re-check-on-use rule
`suggestion_shown` with `suggestion_taken` false, repeated across customers,
is what identifies a suggestion rule that is wrong about a real address.
Inbound and outbound
- The checkout calls a Function URL with an 800 ms client-side timeout. If it does not answer in time the checkout proceeds and the address is recorded as unchecked.
- That timeout behaviour is a test, not a comment. The single most important property of this system is that it cannot break a checkout, and the way to be sure is to assert it.
- The reference credential lives in its own secret and is read only by
av-check. The sweep uses the same function rather than holding its own copy. - The sweep is rate-capped per hour, both to control provider spend and because a backfill hammering a third-party API is a good way to be rate limited during a busy checkout hour.
The model call
- There is no model in this system. Normalisation is rules, lookup is an API, and the suggestion logic is a comparison.
- The tempting use is parsing a free-text address into fields, which a model does well — and most reference APIs already offer a parsing endpoint that is cheaper and deterministic.
- Determinism matters here because the same address must produce the same normalised cache key every time, and a model that phrases one differently on Tuesday breaks the cache.
- If you do parse with a model, cache the parse by input string so it is called once per distinct input, ever.
- The cost page assumes none, which is why reference lookups are the only variable band.
Things worth knowing before you build it
- Cache on the normalised address, never the raw string. It is the difference between a 70% hit rate and a 5% one.
- Never suggest a different house number. Both numbers usually exist, and a suggestion that sends a parcel to a real stranger is worse than the original typo.
- Never block, and test that you cannot. A validation timeout that fails a checkout is a self-inflicted outage.
- Record what was offered and what was chosen. Repeated declines of the same suggestion mean the reference data is wrong about a real address.
- Report failed deliveries, not addresses verified. One is an outcome and the other is activity.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts