App architecture · the layou-dev-kit

A bad import doesn't compile.

The apps we ship are split into modules whose dependencies are checked by the compiler. If presentation imports data, the build stops in red. Nobody has to remember it in code review.

The 5 modules, and what each is allowed to see

The cost of no architecture

What breaks an app is rarely the tech.

  • The 18-month refactor. The app grows, each feature costs more than the last, and you rewrite exactly when you should be speeding up.
  • The dev who can't leave. One person knows why that file exists. The knowledge lives in their head and nowhere in the structure.

The rule

What the compiler refuses

Each module declares its dependencies in its manifest, and this table is their real state. A red cell is a broken build.

What the compiler refuses
Module… core domain data presentation app
core won't compile won't compile won't compile won't compile
domain can import won't compile won't compile won't compile
data can import can import won't compile won't compile
presentation can import can import won't compile won't compile
app can import can import can import can import
  • can import
  • won't compile
// ✅ OK: presentation → domain
import  app/domain/auth/entities/app_user

// ❌ COMPILATION ERROR: data leaking into presentation
import  app/data/auth/datasources/auth_remote_datasource

Tooling

The same shortcut, with and without the kit

What the compiler can't see goes through custom lint rules. They fail on their own, and the kit forbids disabling them with an ignore comment: you refactor.

The same shortcut, with and without the kit Without the kit With
Forbidden import It compiles, and it's broken Compilation error
Oversized component Nobody notices Lint error at 200 lines
Leaf component wired to state It works Lint error
Testing a component Mocks everywhere Zero mocks
The 5 lint rules and their severity
  • A leaf component never touches the state manager error
    It receives everything through its parameters, which makes it testable with zero mocks.
  • A single wiring point between state and the UI tree error
    The connector is the only place state touches the components. Anywhere else is a leak.
  • 50 lines max per function warning
    A longer function hides a decision nobody named.
  • 200 lines max per class warning
    Beyond that, the class does two things. Split it before it hardens.
  • 1 public class per file warning
    The file path becomes the project index: you find things without searching.

What it changes for your app

Four effects, each with its mechanism

Add people without slowing down

The monorepo is split by layer. Two developers on two features don't touch the same modules.

The mechanism
  • A feature is a vertical slice across 4 separate modules
  • Module splitting keeps builds incremental: you don't recompile the whole app
  • A new developer reads 5 folders, and the structure tells them where their code goes
  • Naming conventions are fixed, so the file to create can be deduced

Know upfront what a change touches

When a business rule moves, it moves inside domain, and nothing else compiles differently.

The mechanism
  • domain is tested with no emulator, no database, no UI component
  • Repos are interfaces: swap in a fake with one line of injection
  • Switching databases only touches data

The MVP and the V3 are the same codebase

The layers are laid down on day one, even for an MVP. There's no throwaway code to rewrite once the app starts working.

The mechanism
  • Errors are typed (closed type and exhaustive matching): adding a failure case becomes a compile error everywhere it's missing
  • The return type carries the failure (Either / Result), so the error path is written in the signature
  • Every external SDK is wrapped before it reaches app code
  • Migrations happen layer by layer

Few chokepoints to audit

The architecture sets how many places data can come in through. The fewer there are, the more feasible an audit becomes.

The mechanism
  • The client writes an intent to the database and a server trigger processes it: one entry surface to govern
  • Every write goes through a mandatory use case, the one place to validate
  • Server secrets live in a secret manager, never in a production .env. Client-side, they're injected at compile time
  • A raw SDK exception is translated into a typed business failure at the data boundary, before it reaches the UI

The foundations

SOLID, KISS, YAGNI, and what holds them up

For each principle, the kit mechanism that enforces it on a Tuesday night, on a late feature, when nobody has time to review.

Single Responsibility One file, one reason to change.

One public class per file, 50 lines max per function, 200 per class. Beyond that the lint breaks the build and you split.

Open / Closed Open for extension, closed for modification.

A second implementation lands next to the first (SqlUserRepository beside RestUserRepository) with no renaming and no caller touched. The name carries the tech it uses.

Liskov Substitution One implementation replaces another without surprises.

Every interface has its Fake…, injected in place of the real one in tests. A single test prefix is allowed: Mock, Stub and InMemory side by side would suggest three different contracts.

Interface Segregation One contract per business role.

One interface per feature (AuthRepository, OrderRepository), declared in domain. Reads don't go through a use case: no layer gets added for the sake of symmetry.

Dependency Inversion The business depends on nothing. Everything else depends on the business.

That's the matrix above. domain declares the interface and imports nobody, data implements it, app is the only place the two meet. A backwards arrow doesn't compile.

Keep It Simple The simplest solution that holds.

80% of services are five lines: a class, an injection. The interface, the fake and the typed failures are kept for the 20% where they pay off: payments, auth, anything tested in isolation.

You Aren't Gonna Need It You don't write for a need you don't have.

An interface gets extracted the day a test needs a fake. Pre-abstracting “just in case” is taking on debt for a future that never shows up.

Separation of Concerns Each layer has a single subject.

A leaf component knows only its parameters, and the lint checks it never reaches the state manager. A single point of the UI tree wires state in.

Composition over inheritance You assemble small pieces.

Several small pieces of state watching each other rather than an 800-line god object. On the UI side, a component that contains another one is an organism.

DRY is deliberately missing from this list. It stops at the data boundary, where the DTO duplicates the entity's shape on purpose: a renamed database field breaks one mapper and no screen.

The limits

What it costs, and where it stops

  • Two to three days up front. Scaffolding the monorepo and modules takes 2-3 days on the first project, plus about a day to write the lint rules and their tests. After that, it all carries over from one project to the next.
  • Too heavy for a throwaway prototype. For a hackathon or a prototype binned in two weeks, the 4 layers cost more than they return. They pay off when the code has to outlive its author.
  • Security is still its own job. The kit holds no database access rules, no encryption policy, no GDPR compliance. It reduces the chokepoints, and the audit still has to be done.

In detail

The interactive explorer

Nine views of the same architecture (modules, layers, data flow, injection, errors, naming…), across Flutter/Firebase, React Native/Supabase and TanStack/Convex. The tabs at the top switch stacks. Keyboard: 1 to 9 and Esc, or Auto Tour. The explorer's interface is in French.

Open fullscreen ↗

Is your app starting to feel heavy?

Existing codebase or a fresh start: we look at what this architecture would cost you, and what it would spare you.

Talk about my app