What Building a Small Backend Taught Me About AI-Ready Engineering
A retrospective on the engineering lessons from building a small backend: explicit contracts, append-only state, real geospatial semantics, provider-neutral AI, and evidence-based testing for nondeterministic output.
I recently built a compact backend that creates people, updates their locations, finds nearby results, and generates a short AI-assisted bio from a person’s job and hobbies. The surface area was small enough to look straightforward. The implementation was a useful reminder that small APIs are often compressed systems-design exercises.
The interesting work was not choosing Kotlin or wiring up three controllers. It was deciding what the API promised, what the database had to remember, what the model was allowed to do, and what evidence would make those decisions trustworthy.
1. Turn requirements into a contract before turning them into code
The first lesson was that a short requirements list hides a lot of policy. “Update a location” does not answer whether updates are history, whether an old phone event can arrive late, or what a client retry should do. “Find nearby people” does not answer which distance model is authoritative, whether the radius boundary is inclusive, or whether returned coordinates are the same point used for filtering.
I got better results once those questions became an explicit contract instead of being left to controller code. The API has only three public routes—POST /persons, PUT /persons/{id}/location, and GET /persons/nearby—but each has a deliberately closed request and response shape. Unknown fields are rejected. Coordinates are canonicalized consistently. Errors use stable machine-readable codes rather than leaking rejected values or implementation details.
This sounds formal for a small backend, but it creates a useful order of operations: decide the behavior, encode it in tests and documentation, then implement the path that satisfies it. Without that order, “clean architecture” can become a collection of reasonable guesses that disagree at the edges.
2. Current state and history are different products
The most important data-model decision was to stop treating a person’s location as one mutable column. A location update is both a current-state change and an observation about what happened. Those are related, but they are not the same thing.
The system stores accepted observations as immutable history and maintains a rebuildable last-known projection for reads. A late observation can remain in the history without incorrectly rewinding the current projection. A client update can carry a timestamp and an idempotency key, so replaying the same request returns the original observation instead of creating another row. Reusing a key with different content is a conflict, not a second interpretation of the event.
This made me think more carefully about “update” APIs. If the system may need to explain why a current value won, or recover a projection after a bug, overwriting the past is the wrong optimization. The projection makes the common query fast; the log preserves the facts needed to rebuild and reason about it.
When a write can arrive late, be retried, or be audited later, model the observation and the projection separately.
3. Geospatial correctness lives in the database semantics
Nearby search is easy to demonstrate and surprisingly easy to get subtly wrong. An in-memory Haversine loop can return plausible results while disagreeing with the storage model, the index, or the meaning of a kilometre near the edge of a radius.
The implementation uses PostGIS geography(Point, 4326), a GiST index on the last-known projection, and ST_DWithin for indexed candidate selection. Final membership and ordering use the same spheroidal distance semantics, with unrounded distance deciding the result and a stable UUID tie-breaker making ordering deterministic. The response’s one-decimal distance is presentation; it is not the value used to decide whether someone matched.
The broader lesson is to make the domain’s physical meaning explicit. Latitude and longitude are not just two decimals. Axis order, longitude normalization, poles, the radius boundary, exact-location matches, and the point returned to the caller all belong in the contract. Once the semantics were written down, the query shape became easier to judge: nearby reads query the projection, not the full history and not an application-memory scan.
4. AI should own prose, not truth
The AI requirement created a useful boundary question: what exactly is the model responsible for? The answer became narrower than “generate a bio.” The model may propose short prose. The application remains responsible for input validation, privacy policy, grounding, output shape, length, sentence count, and persistence.
That boundary led to several practical decisions. The bio generator is provider-neutral, so the application does not depend on OpenAI-, Gemini-, or Anthropic-specific response types. The default runtime is deterministic and does not require credentials. Remote generation is explicit and fail-closed. User profile fields are canonicalized before they are used, and the model receives structured, opaque source values rather than a prompt assembled from free-form instructions. The generated result is checked again before it can cross into the stored domain model.
Prompt injection is therefore not treated as a clever prompt-writing problem. It is an input and output boundary problem. A hobby can be valid domain data and still contain language that tries to control the model. High-confidence instruction, credential, contact, URL, and control-token patterns are handled by policy; model output is still treated as untrusted even after the input passes. In a higher-security product, the privacy question would be larger still: a name, job, hobby, and location should not leave the system simply because a model makes the feature more interesting.
The most useful mental model is: the model is a fallible component behind an application-owned interface. It is not the source of truth, the security boundary, or the final validator.
5. Nondeterministic features need invariant tests
Exact-string assertions are the wrong test for AI-authored prose. The useful tests ask whether the output is grounded in the allowed source, stays within the length and sentence limits, avoids forbidden structure, and remains safe when the provider returns malformed or adversarial content.
The test suite consequently has several layers: deterministic unit tests for policy and bounds, contract tests for the HTTP behavior, adapter tests for provider response parsing, and live-provider evaluation that is opt-in rather than part of the ordinary build. The live harness precommits its corpus, call budget, provider, and pacing. It does not retry, choose the best of several answers, or silently fall back to a different provider. That makes a result auditable instead of making the score look better.
One fixed-corpus evaluation produced 300 passing outputs, with no retries, top-ups, fallback, or boundary failures. That is useful revision-scoped evidence that the policy and adapter worked under those recorded conditions. It is not a claim that every future provider response—or production traffic—will behave the same way. The distinction matters. A green experiment is evidence about an experiment, not a warranty about a system.
6. A benchmark harness is not benchmark evidence
The system included a one-million-record benchmark harness. Building the harness clarified another engineering habit: separate the ability to measure something from the result of having measured it.
The implementation includes seed data, query scenarios, write-replay cases, correctness checks, and query-plan inspection. Those artifacts make a future run repeatable. They do not justify a performance headline until the run has actually happened on a named environment with captured timings, plans, cardinalities, and write behavior. In particular, an indexed query plan is not the same as a measured service-level result, and a small local database is not a production workload.
This is a less glamorous lesson than “it scales to a million rows,” but it is more durable: preserve the harness, label the evidence, and keep unknowns visible.
7. Verification is part of the feature
For this backend, Docker Compose, Flyway migrations, real PostGIS tests, HTTP smoke checks, dashboard checks, and traceability documents were not cleanup tasks after the implementation. They were part of the implementation.
That changed how I worked. A migration was not finished until the application could start against it. A nearby query was not finished until the database semantics and response shape agreed. A security rule was not finished until hostile input and provider-shaped failures were tested. A live evaluation was not finished until its evidence was sanitized and its limits were recorded.
The repository is also a record of decisions: why the projection exists, why the public routes remain unversioned, why the deterministic generator is the default, and what the live tests do and do not prove. Documentation became a design tool because writing down the boundary exposed contradictions earlier than code review did.
What I’m taking forward
This work reinforced a simple operating loop for AI-assisted backend work:
- Read the live requirements and repository state before making assumptions.
- Make the contract and the failure behavior explicit.
- Separate durable facts from derived read models.
- Keep AI behind a small application-owned boundary.
- Test invariants, hostile inputs, and integration semantics—not just happy-path strings.
- Label measured evidence separately from scaffolding, intention, and speculation.
The system is small, but the lesson is not. A backend becomes trustworthy at the boundaries: where a request becomes a domain fact, where history becomes current state, where coordinates become a distance, where user text becomes model input, and where a test result becomes a claim. Those boundaries are where I want to spend more of my engineering attention.