All articles

Hexagonal architecture for real, not in theory

June 24, 20265 min read

The starting problem, in a typical Adonis/Lucid backend, always looks the same: a service calls Skin.query().where(...) directly, it works, it's quick to write, and six months later you can't test a single business rule without spinning up a Postgres database. The ORM model and the domain logic are welded together. Changing a storage detail means touching the service. Testing a permission rule means seeding tables.

On the Wakfuli backend, most business modules (items, folders, actions, auth, havenbag, builds, skinator, sublimations) avoid this trap with the same recurring split: domain/, application/ (the ports), adapters/, services/, controllers/, presenters/, validators/. This isn't ceremony bolted on after the fact, it's the default folder structure of a module. The spells module, on the other hand, has neither application/ nor adapters/: it exposes read-only reference data, with no business rule to isolate, and nobody forced the mold onto it. That detail matters: the discipline here isn't dogmatic, it applies where there's a boundary worth defending.

A port and its adapter, not an abstract promise

Take the skinator module, which handles character skin creation. skinator/application/ports.ts defines the interface the domain expects from a skin repository, with no knowledge of Lucid or Postgres:

skinator/application/ports.ts
ts
export interface SkinRepository {
  findByUuidAndUser(uuid: string, userId: number): Promise<Skin | null>;
  findByUuid(uuid: string): Promise<Skin | null>;
  create(data: CreateSkinData): Promise<Skin>;
  mergeAndSave(skin: Skin, data: UpdateSkinData): Promise<Skin>;
  delete(skin: Skin): Promise<void>;
  getUserSkins(userId: number, page: number, limit: number, filters?: SkinFilters): Promise<ModelPaginatorContract<Skin>>;
  getPublicSkins(page: number, limit: number, filters?: SkinFilters): Promise<ModelPaginatorContract<Skin>>;
}

skinator/adapters/skin_repository_adonis.ts implements this interface with Lucid, SQL queries included:

skinator/adapters/skin_repository_adonis.ts
ts
export class AdonisSkinRepository implements SkinRepository { 
  async findByUuid(uuid: string): Promise<Skin | null> {
    return Skin.query().where('uuid', uuid).first();
  }
 
  async create(data: CreateSkinData): Promise<Skin> {
    return Skin.create({
      userId: data.userId ?? null,
      name: data.name.trim(),
      breedId: data.breedId,
      // ...
    });
  }
  // mergeAndSave, delete, getUserSkins, getPublicSkins follow the same pattern
}
  1. 1

    Controller

    The skins_controller.ts controller imports neither Lucid nor the Skin model directly.
  2. 2

    Service

    It goes through SkinsService, which exposes a SkinPresenter on output.
  3. 3

    Port

    The controller to service to port chain is respected end to end, not just on paper.

Where it really costs you, and where it cheats a little

This is where honesty starts. The domain isn't as pure as the folder structure suggests. skinator/domain/skin.ts, supposedly the business entity, is actually a Lucid model: it extends BaseModel, carries @column decorators, knows its table name. It's not a business object decoupled from persistence in the strict DDD sense, it's an Active Record. What the port isolates isn't the shape of the entity, it's how you access it: the queries, the filters, the pagination. The hexagonal boundary here protects data access, not the entity itself. It's a deliberate compromise, not textbook hexagonal, and that's better than a hollow port pretending otherwise.

A second, subtler nuance: SkinsService declares its constructor with private readonly repo: AdonisSkinRepository, the concrete class, not SkinRepository, the interface. The reason is pragmatic: AdonisJS's IoC container automatically resolves concrete classes marked @inject(), with no explicit binding to register for an abstract type. The port exists, the class honors it, the shared types (SkinFilters, CreateSkinData) do come from ports.ts.

Note

But nothing mechanically prevents calling a method that isn't in the interface. Swapping the adapter for a test means either registering a binding in the container, or providing a fake object with the same shape. It's a real discipline cost, not a mechanical one: nothing stops you if you cut the corner.

What it buys you anyway

What this split makes possible, concretely:

Testability

Test the logic in SkinsService (folder resolution, token generation for an anonymous skin, breedId computation) by passing it a fake repository that matches the shape of SkinRepository, with no database, no migration, no SQL fixture to maintain.

Readability

Opening services/skins_service.ts gives you the business logic with no SQL noise, and opening adapters/skin_repository_adonis.ts gives you the SQL with no business noise. Each file answers a single question.

The cost, though, is real and shouldn't be glossed over: nine modules with this split means dozens of small files for operations that, in a more direct style, would fit in a single controller class. On this backend, the application code as a whole runs past lines spread across these layers, for a medium-sized product. Part of that volume is structural: an interface that repeats the signatures of its implementation, a presenter that reformats what the service just computed. It's the kind of detail that makes some people say hexagonal, below a certain team or domain size, looks like folder theater: form without function, layers you pass through without them protecting anything, because nobody enforces them over time.

Here, what avoids that trap isn't the presence of the application/ and adapters/ folders, it's that they're actually traversed the right way: no controller touches Lucid, no service imports a model outside its own module without going through a repository, and the module that doesn't need the split (spells) simply wasn't given it. The discipline is verified by grepping, not by reading folder names.

Note

The takeaway isn't "always do hexagonal" nor the opposite. It's that this pattern pays back its cost when there's a real boundary to protect (storage that might change, a business rule you want to test without an external dependency, a growing team that needs responsibilities to be locatable), and it becomes dead weight the moment you apply it by reflex to a module with neither business complexity nor a need for substitution. The compromise on domain purity, here, isn't a failure of the pattern: it's the sign that it was applied with a precise goal (isolating data access) rather than as a checklist to tick off.