Back to Blog

Reflections on Engineering Cleaner Back-ends

November 9, 2025
William Callahan

Software engineer and founder with a background in finance and tech. Currently building aVenture.vc, a platform for researching private companies. Based in San Francisco.

backendarchitecturespring bootclean architecturerefactoringtype safetykotlinjooqzodapi designboundaries
Reflections on Engineering Cleaner Back-ends

I've been writing code with hygiene and type safety in mind for a while now (or at least trying to). Every function gets explicit return types (even in languages where it's not required), objects get validated, repository methods get tested. But I've noticed I'm not quite strict enough about the other boundaries.

Principles of modularity (e.g., in the 'lego blocks' or 'IKEA furniture' sense) and abstraction always came more naturally to me, but for whatever reason the principles of clean encapsulation and inheritance (other pillars of Object-Oriented Programming) and their boundaries were less intuitive to me, so I find I have to pause and reflect a bit more about them to ensure they are well defined and enforced.

I first published this piece reaching for the textbook answer — a use-case layer, ports and adapters, DTOs at every seam, mappers to keep them in sync. Living with that design pushed me somewhere stricter and simpler, so I've rewritten the second half around where I actually landed: a small set of canonical, strictly-typed shapes owning the boundary — a read model and an all-nullable write model — each validated at that boundary and generating its own client schema. The "before" examples stay — they're still the villains; I only reversed the cure.


Scope creep

Recently, after a flurry of new code/features being pushed, I did my customary practice of reflecting on what went well and what could be improved. So I decided to revisit the book Clean Architecture for a fresh look/reflection... and oh my god I just realized that the author, Robert Martin, is "Uncle Bob"! (the humorous Twitter sensation I've been watching for years, but never put two-and-two together). He has a great blog, too.

Mostly I knew where the trouble spots were even before I revisited Clean Architecture. I was growing rather concerned with the growth of the controllers, repositories, services, and router files, and knew their boundaries weren't clean/clear enough.

Note: the "before" examples below come from an earlier Java + Spring Boot incarnation of this repo. The ideas aren't Java-specific, though — I've since applied the same shape (one typed record at the boundary, generated client schemas downstream) in my TypeScript and Python services too.

// In PeopleV1Controller - boundary violation example
@PutMapping("/detail")
public ResponseEntity<Map<String, Object>> updatePersonDetail(
    @RequestParam(required = false) String id,
    @RequestParam(required = false) String slug,
    @RequestBody Map<String, Object> body) {

    String personId = resolveIdOrThrow(id, slug, s -> personService.resolveIdBySlug(s));
    String normalizedSlug = RequestValidation.normalizeOptionalSlug(body.get("slug"), "slug");
    if (normalizedSlug != null) {
        body.put("slug", normalizedSlug);
    }
    var updated = personService.update(personId, body);
    return ResponseEntity.ok(updated);
}

The worst part is the Map<String, Object> body itself. It has no declared shape, so the compiler can't verify a single field name or type, and every layer that reads the map ends up guessing what's in it at runtime.

What's actually wrong

  • resolveIdOrThrow() and slug normalization are business decisions living in the HTTP adapter.
  • body.put("slug", ...) mutates an untyped bag in place — invisible to callers, invisible to the compiler.
  • The controller should only be translating HTTP → a typed value and back. Nothing here is typed.

Each shortcut created a new dependency direction: change the slug or email rules and you're touching the controller, the persistence code, and every test that came to depend on the exact behavior.

The 'elephant in the room' is that the controller, repository, and service layers are all doing too much — and they're all speaking the same stringly-typed dialect. They should be focused on their primary responsibilities: translating HTTP to a typed value, executing business logic inside a transaction, and persisting typed data.

Here are a couple more, this time about the shape of the abstraction itself:

// BaseDomainService.java - 200+ lines of abstractions
public abstract class BaseDomainService<T, ID> extends BaseListService {
    protected final String domainName;
    protected final BaseDomainRepository<T, ID> baseRepository;

    // Now includes: caching, circuit breakers, generic CRUD,
    // domain column mappings, string column handling, etc.
    @Cacheable("domain:list")
    @CircuitBreaker(name = "domain-operations")
    public List<T> findPage(FilterParams.Where where, String order, String direction, int limit, int offset) {
        return baseRepository.findPage(where, order, direction, limit, offset);
    }

    // Plus more "convenience" methods...
}

The problem shows up the moment I need a PersonService that doesn't want caching but does want custom validation:

@Service
public class PersonService extends BaseDomainService<PersonDTO, String> {
    // Inherits 200+ lines I don't need
    // But still need to override half the methods
    // And now debugging requires understanding the entire hierarchy
}

This is inheritance used as code-sharing rather than as an "is-a" relationship. It's the classic god-base-class: every subclass drags in caching, circuit breakers, and generic CRUD it may not want, and reasoning about any one service means reasoning about the whole hierarchy.

And the untyped Map all the way down, which is the pattern that ties all of this together:

// Controller layer
@PutMapping("/detail")
public ResponseEntity<Map<String, Object>> updatePersonDetail(
    @RequestBody Map<String, Object> body) // Untyped Map

// Service layer - accepts Map
public PersonDTO update(String id, Map<String, Object> request) {
    int rows = personRepository.update(id, request);

// Repository layer - still Map-based
public int update(String id, Map<String, Object> request) {
    if (request.containsKey("nameFirst")) {
        updates.add("name_first = ?");
        args.add(toStr(request.get("nameFirst")));
    }

The same shape is re-interpreted three separate times, in three layers, with no shared definition. When I add a new field like linkedinUrl, I have to update:

  1. OpenAPI annotations in the controller
  2. Map key handling in the service
  3. Column mapping in the repository
  4. The frontend, which is expecting a specific JSON structure it can only discover by reading the backend

One field, four places to change by hand, and the compiler can't catch a single mismatch between them. That "add a field, touch four places" tax is the thing I most wanted to kill.


So what went wrong, exactly?

The issue truly was remembering the strict boundaries for each layer (knowing exactly where one ends and one should begin) — but underneath that, it was letting untyped data cross those boundaries. A Map<String, Object> carries no contract, so a boundary can look clean on the architecture diagram while nothing in the code actually enforces what crosses it. You can't see it from this blog post, but there were/are a lot of clean boundaries in the codebase already; the examples above were some of the worst 'all-in-one' files that I should have put strict boundaries on right out of the gate.

This leads to a particular kind of technical debt that's hard to spot early or measure objectively. It's also the dominant style of code I've often seen online and from AI/LLMs. It passes CI and looks fine in a quick review, and it stays a pain to read and change later.

Sidebar to my past profession

In finance, I became allergic to the phrase "best practices." In reality it usually meant "common practices" at best, and "things people confidently repeat without understanding" at worst. Software engineering has a similar problem.

So you have to be unusually discerning about which "best practices" you accept. This is salient to me now as I write this reflection, because I'm trying to identify what constitutes 'objectively' clean architecture and what is just being pedantic about subjective coding practices. It's also — as you'll see — why I walked back my own first answer.

Rethinking the approach

When I first wrote this piece, I reached for the textbook fix. Business logic scattered across controllers, services, and repositories? Introduce a use-case layer: one class per business action, hidden behind an inbound port (interface CreatePerson), fed a strongly-typed CreatePersonCommand, with a DTO at the HTTP edge and a Person domain entity underneath — and a mapping library like MapStruct to generate the translations between all of them. Ports, adapters, commands, mappers — the whole hexagonal diagram, faithfully reproduced.

Then I lived with it, and something bothered me.

For a single "update a person" operation I was now looking at an UpdatePersonRequest (web), an UpdatePersonCommand (application), and a Person (domain) — three data types describing the same fields — plus a generated mapper whose entire job was to keep those three restatements in sync. I had introduced a drift problem and then imported a library to manage the drift. The *Command objects were anemic: no behavior, just the domain's fields wearing a different name. The mapper existed only because I'd created two things to map between.

That's when it clicked: I could just delete the restatements instead of building a layer to translate between them.

My current belief — and it runs against a lot of mainstream Clean-Architecture and Spring/DDD advice, which argues for more layers, not fewer — is that the cleanest model I've found is barely a "layered architecture" at all:

Per concept, a small fixed set of canonical types — a read model and an all-nullable write model — each bound and validated at the boundary instead of re-parsed at every layer, carried through the Controller and Service into typed SQL with no mapper reshaping it, and generating its own client schema for consumers downstream.

No DTOs, no command objects, no hand-written mappers, no per-aggregate repository ports — a data class, a controller, a service, and a thin store. What I was after was fewer types, held more strictly.

"Fewer types" isn't "one type" — it's no redundant ones. The duplication I deleted was three types describing the same fields for the same operation, stitched together by a mapper. What's left is a small, fixed family with a real reason to differ: a non-null read model (Entity, what a fetch returns) and an all-nullable write model (EntityMutation, what create and update accept). Larger reads get an expanded variant of the read model. None restates another, and nothing maps between them — each is owned in one place and generated once.

Binding the wire straight to the domain type couples your API shape to your internal shape — so you can't evolve them independently — and it puts the burden on you to be deliberate about which fields are writable (the classic "mass-assignment" risk that DTOs exist to sidestep). For this codebase the coupling is a feature: I want the write path to bind to a single type, and I control the writable surface explicitly with that mutation type below. If I needed the public contract to stay stable while an unstable internal model churned underneath it, a DTO layer would earn its keep and I'd add it back.

Here's the collapsed model I actually build to now:

LayerOwnsNever touchesThe one question
ControllerHTTP ↔ data-class binding, status codesbusiness rules, SQL"What did the caller send, and what do I hand back?"
Service (this is the use case)business rules, the transaction boundaryHTTP, JSON, result sets"What does this operation actually do?"
Data class (Entity / EntityMutation)its own shape + invariants; it is the API contractHTTP, persistence, frameworks"Am I a valid, well-typed value?"
Persistence storetyped queries (jOOQ Field<T>)business rules, HTTP"How do I read/write this, in typed SQL?"

Note what left the table since the first draft: the separate use-case class, the inbound port, the command object, and the DTO. The Service is the use case. The data class is the DTO and the domain model at once. That collapse is the whole point.

That persistence-store row leaks a little in practice: a few services reach for DSLContext directly instead of routing every read through a store. I'm fine with that, as long as the SQL stays typed jOOQ rather than sliding back toward stringly-typed queries.

The data class is the contract

The villains above lived in the person/people corner of the code; the Kotlin below is the entity slice — a different part of the codebase with the same underlying problem.

This is the canonical read model. It's a plain Kotlin data class — but it carries typed identity (EntityId, Slug, EntityType — not raw UUID/String), enforces its own invariants, and holds the @Schema metadata that becomes the OpenAPI component and the generated Zod schema:

@Schema(name = "Entity", description = "Flat entity core record — identity, naming, status, source metadata.")
data class Entity(
    @field:Schema(description = "Unique entity identifier", requiredMode = Schema.RequiredMode.REQUIRED)
    val id: EntityId,                 // typed wrapper, never a bare UUID
    @field:Schema(description = "Resolved display brand name", requiredMode = Schema.RequiredMode.REQUIRED)
    val nameBrand: String,
    val slug: Slug,                   // typed wrapper, never a bare String
    val typeRecord: EntityType? = null,
    val foundedYear: Int? = null,
    // ...
) {
    init { require(nameBrand.isNotBlank()) { "nameBrand must not be blank" } }
}

A raw UUID and a raw String are interchangeable everywhere, so nothing stops you passing a person id where an entity id belongs, or a slug where a legal name belongs. EntityId parses and validates a UUID once, at the edge, and then the type carries that guarantee inward — a function that takes an EntityId cannot be handed a PersonId or a bare string by accident. You also stop scattering UUID.fromString(...) through the codebase, because the boundary already did the parsing.

The controller only translates

The controller binds each request body straight to the write model and returns the read model. There's no EntityRequest DTO to construct and no mapper call:

@RestController
@RequestMapping("/v1/entities", produces = [MediaType.APPLICATION_JSON_VALUE])
class EntityController(
    private val entities: EntityService,
) {
    @PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE])
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@Valid @RequestBody body: EntityMutation): Entity =
        entities.create(body)

    @PutMapping("/{id}", consumes = [MediaType.APPLICATION_JSON_VALUE])
    fun update(@PathVariable id: EntityId, @Valid @RequestBody body: EntityMutation): Entity =
        entities.update(id, body)
}

Each method is a single line: Spring and Jackson deserialize the body into EntityMutation, @Valid runs the constraints declared on that type, the service does the work, and the returned Entity is serialized straight back out. The controller knows about HTTP and nothing else — even {id} arrives already parsed into a typed EntityId.

The service owns the rules and the transaction

@Service
class EntityService(
    private val store: EntityStore,               // owns the jOOQ DSLContext
    private val tx: WriteTransactionRunner,        // owns the transaction boundary
) {
    fun update(id: EntityId, input: EntityMutation): Entity {
        input.requireDistinctNames()               // a business rule, on the type
        return tx.execute { store.applyMutation(id, input) }
    }
}

Business rules live either on the data class (requireDistinctNames(), the init block) or in the service. The transaction boundary is explicit — the service opens it and owns it — rather than relying on scattered @Transactional annotations whose scope you have to infer. (The WriteTransactionRunner above is just my own thin wrapper over Spring's TransactionTemplate — what matters is that the boundary is visible and the service controls it, not the particular helper.) The service never sees JSON and never sees a ResultSet.

Persistence is a thin store that owns the jOOQ DSLContext and works in typed Field<T> columns rather than a Map<String, Object>, raw ResultSet loops, or string-built SQL:

@Repository
class EntityStore(private val dsl: DSLContext) {
    fun existsById(id: EntityId): Boolean =
        dsl.fetchExists(ENTITY, ENTITY.ID.eq(id.value()))
    // ...typed jOOQ the whole way down
}

I still keep a persistence store as its own class, but notice what it isn't: there's no EntityRepository interface, no EntityRepositoryPort, no adapter implementing it. This is where I most consciously depart from mainstream Spring Data, which would hand you a JpaRepository here — so treat it as an opinion, not a rule. jOOQ is already a typed data-access API, so wrapping it in a port would create an abstraction with exactly one implementation and no second caller. For my purposes that's ceremony; if you were genuinely swapping persistence engines, or wanted a stable port to fake in tests, the interface would earn its place. (I do use ports — just only at genuine external boundaries: auth, LLM inference, geocoding, error reporting. Things I might actually swap or need to fake. Never over my own tables.)

One write type: RFC 7396

The other thing that collapsed was "create request vs. update request vs. patch request." Instead there's a single all-nullable *Mutation data class that serves create, update, and RFC 7396 merge-patch at once:

@Schema(
    name = "EntityMutation",
    description = "One body for create, update, and RFC 7396 merge-patch. Omitted nullable fields leave existing values unchanged."
)
data class EntityMutation(
    val nameBrand: String? = null,
    val nameLegal: String? = null,
    val slug: String? = null,
    val foundedYear: Int? = null,
    // ...every field nullable, every field defaulted
) {
    fun requireDistinctNames() { /* create/update guard */ }

    companion object {
        fun from(entity: Entity): EntityMutation = /* build the merge-patch baseline */
    }
}

All-nullable patch types have a well-known catch: a nullable field on its own can't tell "the caller omitted this" apart from "the caller explicitly set it to null" — both arrive as null on the data class. Plain nullable Kotlin is two states, and merge-patch needs three.

The fix is to resolve presence at the HTTP boundary, not in the domain. For a merge-patch, the boundary reads the raw JSON — where "absent" and "present-but-null" are still distinguishable — and produces a plain EntityMutation plus a record of which fields were actually supplied. Everything past that point is typed: the service loads the current row and applies only the supplied fields, and a full PUT reuses the same update() path with every field supplied. The domain gets to work in one plain nullable type instead of a PatchValue<T> wrapper — but only because the boundary tracks presence for it. The type system won't do that part on its own.

Validate once, generate once

Keeping everything on one type pays off here. That @Schema metadata on the Kotlin data class is the single place field rules are declared — and a build step emits the OpenAPI document from the running app, then converts each OpenAPI component into a Zod schema. The rule I hold is one-to-one: one data class → one OpenAPI component → one Zod schema, so there's never a question of which schema is authoritative. The generated file even points back at its owner:

// LLM AGENTS MAY NOT EDIT THIS FILE — generated from Kotlin data classes via OpenAPI.
// @ownerSourceFile src/main/kotlin/vc/aventure/domain/model/entity/Entity.kt
import { z } from "zod/v4";

export const EntitySchema = z.object({
  id: z.uuid(),
  nameBrand: z.string(),
  slug: z
    .string()
    .regex(/^[a-z0-9_-]+$/)
    .max(255),
  foundedYear: z.int().nullish(),
  // ...
});

export type Entity = z.infer<typeof EntitySchema>;

Zod is my favorite validation + type-inference tool in TypeScript. The frontend and every downstream consumer import a generated schema — regenerated from the same Kotlin owner whenever it changes — instead of hand-writing types that guess at the backend's shape. The slug regex, the required fields, the nullability all come from that one Kotlin declaration.

So go back to that "add linkedinUrl, touch four places" tax from the top. Adding it to the contract now means one property on the data class, from which a build step regenerates the OpenAPI component, the Zod schema, the TypeScript type, and the validation rules. The persistence side still takes work — a migration for the new column, a jOOQ regen, and the store's read and write updated to carry it — but the entire client-facing contract, which used to be three of those four hand-edited places, fans out from that single property. That fan-out is a code generator doing work I used to do by hand, so the single source of truth only holds while the pipeline actually runs.

The mapper I didn't need

Which brings me back to MapStruct. In the first draft of this article I wrote that compile-time mapping "eliminates a category of bugs where DTOs and domain models get out of sync." That's true — but it's solving a problem I gave myself. If the DTO and the domain model are the same class, there's nothing to map and no mapper to generate. The whole bug category disappears, because there aren't two representations to keep in sync to begin with.

A lot of "clean architecture" machinery exists to manage duplication you introduced in the name of clean architecture.


Standardizing the error contract

Errors are the one boundary where a distinct response type earns its place — a ProblemDetail is a transport shape, not domain data, so it sits outside the read/write family as the one type that exists purely for transport. I've moved ad-hoc error bodies onto RFC 9457 Problem Details, which makes client integration predictable, cuts false-alarm Sentry noise, and maps straight from Jakarta Bean Validation failures — the same @Valid that runs on the data class. Spring gives it a built-in structure:

@RestControllerAdvice
class ApiExceptionHandler {
    @ExceptionHandler(DuplicateEntityException::class)
    fun handleDuplicate(ex: DuplicateEntityException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(
            HttpStatus.CONFLICT,
            "Entity with slug ${ex.slug} already exists",
        )
}

What the repo tree actually looks like

Not the aspirational hexagonal diagram I sketched the first time — the real thing:

src/main/kotlin/vc/aventure/
├── boot/ # Spring wiring: config, security, web filters, serialization
├── domain/
│ ├── model/ # canonical data classes — one per concept (Entity, EntityMutation, …)
│ ├── service/ # pure domain rules (framework-free)
│ ├── port/ # a handful of ports — EXTERNAL systems only (auth, LLM, geocoding…)
│ └── exception/
├── application/
│ └── usecase/ # the @Service layer: business logic + transaction boundaries
└── adapters/
  ├── inbound/web/ # controllers — HTTP ↔ data class, nothing else
  └── outbound/ # persistence *Store classes that own the jOOQ DSLContext

The source is Kotlin all the way through, and the only Java in the tree is generated jOOQ — so I never hand-write the database types.


Having clear boundaries

The right amount of friction appears to prevent the wrong shortcuts without noticeably slowing legitimate development. Once boundaries are agreed upon and enforced by types and tests rather than convention, architecture debates happen a lot less often. The code either compiles and finishes the CI/CD pipeline or it doesn't. Discussions shift from "where should this logic live?" to "what's the right business rule?"

I let the language's own linter handle the ordinary cases; for the boundary rules it can't express — the ones specific to this codebase — I write a handful of ast-grep patterns and run them as a lightweight add-on in my lint script and pre-commit hook. They catch the project-shaped smells a stock linter never will: an untyped Map crossing a boundary, a raw UUID where a typed id belongs. A few targeted rules, no heavier architecture-test framework required.

My mental model is still "dependencies point inward": controllers depend on services, services depend on domain, and the domain never knows about HTTP, databases, or frameworks. What changed is that I no longer believe you need a fleet of ports, commands, DTOs, and mappers to express that. A strictly-typed data class that owns its own shape — and generates everyone else's — has been the cleanest boundary I've built to. It fits the constraints I actually have, and I wouldn't pitch it as the right answer for everyone.

Similar Content

Home
CV
ExperienceEducation
ProjectsBookmarksInvestmentsContactBlog