AGA — Address Gateway
On this page
- At a glance
- What it does
- A. Serves the administrative hierarchy one level at a time
- B. Uses the official region codes as identifiers
- C. Finds a zone by part of its name
- D. Ships its own data
- E. Reports honestly whether it can answer
- Interface
- Standards
- Inside
- Design decisions
- Specifications
- Related
- Terms
AGA — Address Gateway — is a small HTTP service, written in Rust, that answers one question for any application that asks for an Indonesian address: which provinces exist, which regencies and cities belong to a province, which districts to a regency, and which villages to a district. It carries the whole administrative hierarchy with it — 38 provinces down to 83,768 villages, keyed by the official Ministry of Home Affairs region codes — and serves it as four short lookups, each one level deeper than the last.
I built it once and reuse it in every project with an address form, because each of them needs the same cascading dropdowns, the same codes, and the same search, and keeping a copy of eighty thousand villages inside every application means eighty thousand rows to keep identical everywhere. One service with one copy of the data gives every application the same answer.
At a glance
| Language & framework | Rust, with actix-web and sqlx on PostgreSQL |
|---|---|
| Architecture | Clean Architecture |
| Version | 2.1.0 |
| Status | Stable, in production |
| Form | A standalone HTTP service behind an API gateway, reused across projects |
| Source code | Private |
What it does
A. Serves the administrative hierarchy one level at a time
An address form fills its dropdowns in order: the province first, then the regency or city within it, then the district, then the village. AGA mirrors that order exactly. Each lookup takes the code chosen one level up and returns the level below it, sorted by name, so a form never downloads more than the list it is about to show.
B. Uses the official region codes as identifiers
Every zone is identified by its dotted Ministry of Home Affairs code — 11 for a province, 11.01 for a regency, 11.01.01 for a district, 11.01.01.2001 for a village — rather than an internal number. An application can store the code beside an address and read it back years later, from AGA or from any other source that uses the same codes.
C. Finds a zone by part of its name
Every lookup accepts an optional search term that matches anywhere in a name, regardless of case, so a user typing a few letters of a village sees only the villages that fit. The term is trimmed, an empty one means no filter, and the results are capped so that a one-letter search stays cheap.
D. Ships its own data
The tables and the full data set are compiled into the service itself, as database migrations. A fresh database becomes a complete region API in one start, with no separate import step and no file to fetch.
E. Reports honestly whether it can answer
A readiness check asks the database for a trivial answer and reports 200 only when it gets one, and 503 when it does not, so a container platform or a parent service can tell an AGA that runs from an AGA that can actually serve.
Interface
| Capability | Purpose |
|---|---|
| Provinces | every province |
| Regencies and cities | the regencies and cities of one province |
| Districts | the districts of one regency or city |
| Villages | the villages of one district |
| Readiness | whether the database answers; without authentication |
| Version | the service name and version; without authentication |
AGA can be mounted under any route of a gateway, and every lookup answers in the same envelope, with the request id the caller sent or one AGA generated:
Standards
| Standard | What it guarantees here |
|---|---|
| Ministry of Home Affairs region codes | identifiers that other Indonesian systems recognise, validated level by level |
| W3C Trace Context and OpenTelemetry (OTLP, semantic conventions) | traces, logs, and metrics that continue the caller's trace across services |
| UUIDv7 | request ids that never collide and sort by time |
| OpenAPI 3.0.3 | a machine-readable contract for every endpoint |
Inside
AGA follows Clean Architecture. The domain holds the four zones, their code formats, and the rules for a name and a search term; the application layer holds one use case per lookup and the ports they need — a repository per level, a database health check, and an authenticator; PostgreSQL, gateway identity, configuration, and telemetry each live in the infrastructure layer; and a single composition root is the only place that knows all four layers at once. Because every use case receives its repository through a port, its 60 tests run against in-memory repositories and a fake health check, without a database.
Design decisions
The data travels inside the binary, and loading it is a decision, not a side effect. I compile the migrations into the service, so the image that runs is the image that carries the data, and no deployment can pair one version of the code with another version of the regions. Applying them is opt-in through a configuration flag, because a service that rewrites its schema every time it starts is a service that can surprise a production database. The migrations run on a connection of their own with statement logging switched off: the village seed is one insert of almost five megabytes, the database library logged it in full as a slow statement, and that single record was larger than the telemetry collector accepts, so the collector dropped the whole batch it arrived in — unrelated logs included. At start-up the service also asks the database for a trivial answer and logs the result, without refusing to start, so a database that is still booting does not take the service down with it.
Every row knows its whole ancestry. A village stores its own code and also the codes of its district, its regency, and its province, and each of those columns is indexed and tied to its parent table. That costs a few bytes per row and buys lookups that never need a join, at any level, and room for any future question such as "every village in this province" without changing the schema. Names are indexed as well, for the search.
Invalid input stops at the door, and invalid data never leaves. Each level has its own code format, checked by a regular expression in the domain before a query is built, so a malformed code is a 400 with a message naming the field, not an empty list that looks like an answer. A name is trimmed, may not be empty, and may not be longer than its column. The same rules apply in the other direction: a row in the database that breaks them is treated as a server fault and logged, rather than passed on to a form. A search term is trimmed, limited in length, always sent to the database as a bound parameter rather than pasted into the query, and its results are capped and sorted by name.
Readiness means the database answers. An earlier version exposed a health check at the root that always returned 200 because it checked nothing, so a container that had lost its database still looked healthy, and the service in front of it could not tell. I replaced it with a readiness check under the service's own prefix that runs a trivial query and returns 503 with a flat body naming the failed dependency — the same shape as the other modules it runs beside, so one aggregate check can read them all. I removed the old check rather than keeping both, and the test that proves the 503 was itself proved by sabotage: forcing the handler to always answer 200 made it fail.
Identity comes from the gateway, not from a token. AGA sits behind an API gateway that authenticates every caller and passes on only verified identity, the same contract as the rest of my modules, and that identity is recorded on the request's trace.
One contract, kept exactly. Every response carries the same envelope and a request id, accepted from the caller or generated, and echoed in a header. The 400 body is the one deliberate exception: it carries no request id inside the body, because the version AGA replaced did not, and applications already parse that shape byte for byte.
Every request can be followed across services. Telemetry moved from writing straight to Elasticsearch to OpenTelemetry through a collector. An incoming trace context is continued, so a lookup appears as a child of the page that asked for it; metrics use delta temporality, because the collector's Elasticsearch exporter drops cumulative histograms and counters; HTTP metrics are labelled by route pattern rather than raw path, so a hundred thousand codes do not become a hundred thousand series; the client address comes from forwarded headers only when they arrive from trusted proxies; and telemetry can be switched off, leaving standard output, with a flush on shutdown when it is on. The trace pipeline sees spans only: before that change, every log line was exported twice, once as a span event and once as a log record.
It is a provider, so the consumer owns the deployment. The image is built in two stages, runs as a non-root user, and reads its configuration from a mounted file. AGA no longer carries a deployment of its own: each project that uses it runs it inside its own stack, with its own database and its own collector, because the project knows how it is backed up and where its telemetry goes.
Specifications
| Inputs | Requests from applications behind an API gateway, carrying verified identity |
|---|---|
| Outputs | Lists of zones with their codes, parent codes, and names, in one JSON envelope |
| Data | 38 provinces, 514 regencies and cities, 7 277 districts, 83 768 villages |
| Storage | PostgreSQL, with tables and data applied by embedded migrations |
| Limits | Search terms and results both bounded |
| Observability | OpenTelemetry traces, logs, and metrics over OTLP, or standard output only |
| Tests | 60, without a database |
| Runs as | A container image built in two stages, as a non-root user, with configuration mounted from a file |
Related
Where AGA's traces, logs, and metrics go once they leave the service.
Terms
- Administrative hierarchy
- Indonesia's four nested levels of region: province, regency or city, district, and village.
- Region code
- The dotted identifier the Ministry of Home Affairs assigns to every region, longer at each level, such as
11.01.01.2001. - Migration
- A versioned script that changes a database's schema or data, applied once and recorded so it is never applied twice.
- Readiness check
- An endpoint that reports whether a service can serve requests right now, not merely whether its process is running.
- API gateway
- A service in front of other services that authenticates callers and forwards only verified identity to them.
- Port
- An interface the inner layers define and the outer layers implement, so the rules never depend on a particular tool.
Technologies covered
- Rust
- PostgreSQL
- OpenTelemetry