Interface Segregation & Dependency Inversion · Will Madden
← → navigate · F fullscreen

Two techniques

Segregating Interfaces
& Dependency Inversion

Two new techniques, and how to apply them in a concrete example from Prisma Next.

Will Madden · PrismaTypeScript Berlin

Where we left off

Alexey gave us two ideas

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

zsh
$ 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

PostgresTableNode schema · name columns · catalog codecRegistry parent · types … dozens more extract PrintableTable { schema, name, columns } PrintableColumn { name, type, nullable }
// 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

CLI · printer Postgres MySQL

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

CLI Postgres MySQL Before: CLI depends on targets CLI declares PrintableNode Postgres MySQL After: targets depend on CLI

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.

CLI · printer PrintableNode Postgres MySQL SQLite ✦

0 changes to the printer — no union to extend, no branch to add.

Settled

Injection ≠ inversion

Dependency injection

Delivery

How you get your dependencies — you receive them, instead of importing them.

Dependency inversion

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" }

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

Next: André — hexagonal architecture