Two techniques
Segregating Interfaces
& Dependency Inversion
Two new techniques, and how to apply them in a concrete example from Prisma Next.
Where we left off
Alexey gave us two ideas
- Depend on the interface, not the concretion
- Dependency injection — receive your dependencies, don't import them
I'll take it two steps further:
interface segregation & dependency inversion.
⚠️ injection ≠ inversion — they sound the same, they're different.
The example
Print a database schema
$ prisma db schema
public.users
id int4 not null
email text not null
created_at timestamptz
public.posts
id int4 not null
author_id int4 not null
title text
Each line is one element of the schema — a table, or a column. Postgres qualifies a table by its schema: public.users.
The naive implementation
The obvious solution
// the concrete node — a real class with real dependencies
class PostgresTableNode {
schema: string;
name: string;
columnNodes: PostgresColumnNode[];
// + catalog, codec registry, parent links …
}
// the printer reads the Postgres node shape directly
function printTables(tables: PostgresTableNode[]) {
for (const t of tables) {
line(`${t.schema}.${t.name}`); // e.g. public.users
printColumns(t.columnNodes);
}
}
It works — Postgres is our first target. More will come.
Problem #1 — testing
I want to write a unit test
// printer.test.ts — just to print "public.users"
const table = new PostgresTableNode({
schema: publicSchema, // needs the schema/catalog
name: "users",
columns: [new PostgresColumn("id", new PostgresType("int4", codecRegistry))],
}); // …and a codec registry
All of that machinery, just to check a string. It breaks for reasons that have nothing to do with printing.
Apply Alexey's lesson — but smaller
Depend on a smaller interface
// what the printer actually needs — one small interface per concept
interface PrintableColumn { name: string; type: string; nullable: boolean; }
interface PrintableTable { schema: string; name: string; columns: PrintableColumn[]; }
function printTables(tables: PrintableTable[]) {
for (const t of tables) {
line(`${t.schema}.${t.name}`);
for (const c of t.columns) line(` ${c.name} ${c.type}`);
}
}
// the test is now trivial — plain objects, no catalog, no codec registry
const table = {
schema: "public",
name: "users",
columns: [{ name: "id", type: "int4", nullable: false }],
};
The principle
Interface segregation
// the interface is tiny, so test fixtures are too
const tableNodeFactory = (name: string, ...cols: PrintableColumn[]): PrintableTable =>
({ schema: "public", name, columns: cols });
printTables([tableNodeFactory("users", { name: "id", type: "int4", nullable: false })]); // ✓ one line
Declare the smallest interface for one concept — and a whole class of fixtures becomes a one-liner.
Problem #2 — coupling
Now we add MySQL
- The printer must handle Postgres and MySQL tables
- MySQL has no "schema" — it qualifies by database:
app.users
Segregation alone isn't enough
The printer is coupled to both targets
interface PostgresPrintableTable { schema: string; name: string; columns: PrintableColumn[]; }
interface MySqlPrintableTable { database: string; name: string; columns: PrintableColumn[]; }
function printTables(tables: (PostgresPrintableTable | MySqlPrintableTable)[]) {
for (const t of tables) {
line("schema" in t ? `${t.schema}.${t.name}` : `${t.database}.${t.name}`); // 🤮
}
}
Each interface is shaped around its implementer. Add SQLite → a third branch. The printer accretes target-specific knowledge.
The move
Invert it — the printer says what it needs
// declared by the consumer — the printer. No schema, no database, no table.
interface PrintableNode {
label: string; // already qualified: "public.users"
attributes: string[]; // e.g. a column: ["int4", "not null"]
children: PrintableNode[];
}
class PostgresTableNode implements PrintableNode {
get label() { return `${this.schema}.${this.name}`; } // schema.name
attributes = []; // a table has none
get children() { return this.columns; } // each column is a PrintableNode too
}
The consumer — the printer — defines the interface it depends on. Targets implement the interface. The printer is now compatible with any target that implements it. (Real code: SchemaViewCapable.toSchemaView().)
The pattern
Dependency inversion
The key property: target-specific details can't leak into the CLI — it has no way to reach them through the Printable interface. Clean architecture draws this as rings; we draw it as layers.
The payoff
Add SQLite. The printer doesn't move.
0 changes to the printer — no union to extend, no branch to add.
Settled
Injection ≠ inversion
Delivery
How you get your dependencies — you receive them, instead of importing them.
Direction
Which way dependencies point — at abstractions, toward the core.
Related, but not the same.
Pays off the intro
We don't trust ourselves — a machine enforces it
// architecture.config.json
"rules": { "downward": "allow", "upward": "deny" }
- A rule you have to remember is a rule you'll eventually break — and so will your agent
- A wrong-way import fails
pnpm lint:deps→ build goes red
This is the structural I promised you in the intro. The architecture is executable, not a wiki diagram that rots.
Conclusion
Three small, repeatable moves
- Interface segregation — small interfaces, one per concept
- Dependency inversion — the consumer declares the interface; details depend inward, one direction
- Structural enforcement — don't rely on convention; let a machine hold the line
Next: André — hexagonal architecture