01 Shallow abstractions 6 chapters

Theme 03 · Anti-Incidental Complexity

Shallow abstractions

Explanation

Shallow abstractions

Plain Human Explanation

A layer should earn the extra click it asks from the reader. If a function only renames another function, the product behavior is not easier to understand. It is just farther away.

A useful abstraction makes the important idea smaller: it names a policy, protects an invariant, or hides a messy boundary. A shallow abstraction gives you a new name without removing any real complexity.

Technical Explanation

In TypeScript, shallow abstractions often look like manager classes, service wrappers, or helper functions that pass the same fields through to another API. The caller still has to understand the underlying API, but now they also have to inspect the wrapper.

Prefer the direct call until the abstraction can say something meaningful: “register a trial account,” “price a renewal invoice,” or “record a refund.” If the new function has the same inputs, same outputs, and same failure modes as the thing it wraps, it is probably not helping yet.

Why It Matters

  • User impact: fewer product bugs slip through because reviewers can see the real rule in one place.
  • Product behavior: important workflows stay named after user outcomes, not after generic layers.
  • Risk: shallow wrappers make simple changes feel bigger and hide the line that actually needs review.
  • Decision point: keep a wrapper when it reduces repeated policy or protects a boundary; remove it when it only forwards a call.

The Core Move

Collapse wrappers that do not change the level of understanding. When you add a function or class, make sure its name, inputs, and return value explain a product idea the lower-level API does not.

Small Example

Shallow abstractions: Small Example

Bad TypeScript Example

type Price = {
  amountCents: number;
  currency: "USD";
};

function makePrice(amountCents: number): Price {
  return { amountCents, currency: "USD" };
}

export function createInvoiceLine(label: string, amountCents: number) {
  return {
    label,
    price: makePrice(amountCents),
  };
}
type Price = {
  amountCents: number;
  currency: "USD";
};

function makePrice(
  amountCents: number,
): Price {
  return {
    amountCents,
    currency: "USD",
  };
}

export function createInvoiceLine(
  label: string,
  amountCents: number,
) {
  return {
    label,
    price: makePrice(amountCents),
  };
}

Good TypeScript Example

type Price = {
  amountCents: number;
  currency: "USD";
};

type InvoiceLine =
  | { ok: true; label: string; price: Price }
  | { ok: false; error: "invalid-amount" };

export function createInvoiceLine(label: string, amountCents: number): InvoiceLine {
  if (!Number.isInteger(amountCents) || amountCents < 0) {
    return { ok: false, error: "invalid-amount" };
  }

  return {
    ok: true,
    label,
    price: { amountCents, currency: "USD" },
  };
}
type Price = {
  amountCents: number;
  currency: "USD";
};

type InvoiceLine =
  | {
      ok: true;
      label: string;
      price: Price;
    }
  | {
      ok: false;
      error: "invalid-amount";
    };

export function createInvoiceLine(
  label: string,
  amountCents: number,
): InvoiceLine {
  if (
    !Number.isInteger(amountCents) ||
    amountCents < 0
  ) {
    return {
      ok: false,
      error: "invalid-amount",
    };
  }

  return {
    ok: true,
    label,
    price: {
      amountCents,
      currency: "USD",
    },
  };
}

What Changed

  • The bad version adds makePrice, but the wrapper does not validate, rename a product rule, or hide a difficult boundary.
  • The good version puts the invoice rule where the line is created: amounts must be whole cents and cannot be negative.
  • The remaining structure explains a real decision instead of creating a pass-through layer.

Realistic Example

Shallow abstractions: Realistic Example

This example uses trial registration, where a wrapper around the database looks organized but does not actually clarify the workflow.

Bad TypeScript Example

type TrialInput = {
  userId: string;
  email: string;
};
type UserDatabase = {
  users: {
    insert(row: { userId: string; email: string; status: string }): Promise<{
      id: string;
    }>;
  };
};

class UserCreator {
  constructor(private db: UserDatabase) {}

  create(input: TrialInput) {
    return this.db.users.insert({ ...input, status: "trial" });
  }
}

export function registerUser(input: TrialInput, db: UserDatabase) {
  return new UserCreator(db).create(input);
}
type TrialInput = {
  userId: string;
  email: string;
};
type UserDatabase = {
  users: {
    insert(row: {
      userId: string;
      email: string;
      status: string;
    }): Promise<{
      id: string;
    }>;
  };
};

class UserCreator {
  constructor(private db: UserDatabase) {}

  create(input: TrialInput) {
    return this.db.users.insert({
      ...input,
      status: "trial",
    });
  }
}

export function registerUser(
  input: TrialInput,
  db: UserDatabase,
) {
  return new UserCreator(db).create(input);
}

Good TypeScript Example

type TrialRegistration = {
  userId: string;
  email: string;
};

type TrialUserRecord = TrialRegistration & {
  status: "trial";
  trialEndsAt: Date;
};

type TrialUserStore = {
  createTrialUser(record: TrialUserRecord): Promise<{
    id: string;
  }>;
};

function addDays(date: Date, days: number) {
  const next = new Date(date);
  next.setDate(next.getDate() + days);
  return next;
}

export function registerTrialUser(input: TrialRegistration, store: TrialUserStore, now: Date) {
  const trialEndsAt = addDays(now, 14);

  return store.createTrialUser({
    ...input,
    status: "trial",
    trialEndsAt,
  });
}
type TrialRegistration = {
  userId: string;
  email: string;
};

type TrialUserRecord = TrialRegistration & {
  status: "trial";
  trialEndsAt: Date;
};

type TrialUserStore = {
  createTrialUser(
    record: TrialUserRecord,
  ): Promise<{
    id: string;
  }>;
};

function addDays(date: Date, days: number) {
  const next = new Date(date);

  next.setDate(next.getDate() + days);

  return next;
}

export function registerTrialUser(
  input: TrialRegistration,
  store: TrialUserStore,
  now: Date,
) {
  const trialEndsAt = addDays(now, 14);

  return store.createTrialUser({
    ...input,
    status: "trial",
    trialEndsAt,
  });
}

What Changed

  • The bad version wraps db.users.insert without making the registration rule easier to see.
  • The good version names the product workflow and includes the real policy: a trial lasts 14 days.
  • The store boundary is still small, but it exists because the workflow needs persistence, not because every call needs a class.

System Example

Shallow abstractions: System Example

At system scale, shallow abstractions often appear as service classes that only rename clients. The codebase feels layered, but the product workflow is still hidden.

Larger System-Level Bad TypeScript Example

type RegistrationInput = {
  userId: string;
  email: string;
};
type UserClient = {
  create(input: RegistrationInput): Promise<{
    id: string;
    email: string;
  }>;
};
type EmailClient = {
  send(to: string, subject: string): Promise<void>;
};
type AuditClient = {
  write(event: string, data: object): Promise<void>;
};

class UserService {
  constructor(private users: UserClient) {}
  create(input: RegistrationInput) {
    return this.users.create(input);
  }
}

class EmailService {
  constructor(private email: EmailClient) {}
  sendWelcome(to: string) {
    return this.email.send(to, "Welcome");
  }
}

export class RegistrationManager {
  constructor(
    private users: UserService,
    private email: EmailService,
    private audit: AuditClient,
  ) {}

  async run(input: RegistrationInput) {
    const user = await this.users.create(input);
    await this.email.sendWelcome(user.email);
    await this.audit.write("user.created", user);
    return user;
  }
}
type RegistrationInput = {
  userId: string;
  email: string;
};
type UserClient = {
  create(
    input: RegistrationInput,
  ): Promise<{
    id: string;
    email: string;
  }>;
};
type EmailClient = {
  send(
    to: string,
    subject: string,
  ): Promise<void>;
};
type AuditClient = {
  write(
    event: string,
    data: object,
  ): Promise<void>;
};

class UserService {
  constructor(private users: UserClient) {}
  create(input: RegistrationInput) {
    return this.users.create(input);
  }
}

class EmailService {
  constructor(private email: EmailClient) {}
  sendWelcome(to: string) {
    return this.email.send(to, "Welcome");
  }
}

export class RegistrationManager {
  constructor(
    private users: UserService,
    private email: EmailService,
    private audit: AuditClient,
  ) {}

  async run(input: RegistrationInput) {
    const user =
      await this.users.create(input);

    await this.email.sendWelcome(
      user.email,
    );

    await this.audit.write(
      "user.created",
      user,
    );

    return user;
  }
}

Larger System-Level Good TypeScript Example

type RegistrationInput = {
  userId: string;
  email: string;
};
type RegisteredUser = {
  id: string;
  email: string;
  trialEndsAt: Date;
};

type RegistrationPorts = {
  users: {
    createTrial(
      input: RegistrationInput & {
        trialEndsAt: Date;
      },
    ): Promise<RegisteredUser>;
  };
  email: {
    sendWelcome(to: string): Promise<void>;
  };
  audit: {
    record(
      event: "trial_account.registered",
      data: {
        userId: string;
      },
    ): Promise<void>;
  };
};

function trialEndDate(now: Date) {
  const next = new Date(now);
  next.setDate(next.getDate() + 14);
  return next;
}

export async function registerTrialAccount(input: RegistrationInput, ports: RegistrationPorts, now: Date) {
  const user = await ports.users.createTrial({
    ...input,
    trialEndsAt: trialEndDate(now),
  });

  await ports.email.sendWelcome(user.email);
  await ports.audit.record("trial_account.registered", { userId: user.id });

  return user;
}
type RegistrationInput = {
  userId: string;
  email: string;
};
type RegisteredUser = {
  id: string;
  email: string;
  trialEndsAt: Date;
};

type RegistrationPorts = {
  users: {
    createTrial(
      input: RegistrationInput & {
        trialEndsAt: Date;
      },
    ): Promise<RegisteredUser>;
  };
  email: {
    sendWelcome(to: string): Promise<void>;
  };
  audit: {
    record(
      event: "trial_account.registered",
      data: {
        userId: string;
      },
    ): Promise<void>;
  };
};

function trialEndDate(now: Date) {
  const next = new Date(now);

  next.setDate(next.getDate() + 14);

  return next;
}

export async function registerTrialAccount(
  input: RegistrationInput,
  ports: RegistrationPorts,
  now: Date,
) {
  const user =
    await ports.users.createTrial({
      ...input,
      trialEndsAt: trialEndDate(now),
    });

  await ports.email.sendWelcome(user.email);

  await ports.audit.record(
    "trial_account.registered",
    {
      userId: user.id,
    },
  );

  return user;
}

What Changed

  • The bad version has several services, but most of them only forward calls.
  • The good version keeps the workflow visible and names the one product rule the system cares about.
  • The ports are small because they describe real outside effects, not because every client needs a wrapper class.

When To Use It

Shallow abstractions: When To Use It

Use This When

  • A wrapper makes a simple change require opening several files.
  • A new layer has nearly the same inputs and outputs as the thing underneath it.
  • The abstraction name is generic, such as manager, service, adapter, or helper, and the code inside is mostly forwarding.

Avoid This When

  • The wrapper names a stable product operation that callers should not rebuild.
  • The wrapper hides a genuinely messy external API and presents a cleaner local contract.
  • Several callers need the same policy, validation, or transaction boundary.

Tradeoffs

Removing a wrapper can feel like making the code “less architected.” The payoff is that readers see the product behavior directly. Keep the abstraction only when it reduces the amount a caller needs to know.

  • Vague helpers
  • Mega-services
  • Repository-per-table patterns

Practice Prompt

Shallow abstractions: Practice Prompt

Beginner Exercise

Find a wrapper function or class that mostly forwards to another call. Write down what extra product meaning it adds.

Intermediate Exercise

Inline one shallow wrapper or rename it so it captures a real product operation. Keep the public behavior unchanged.

Stretch Exercise

Find two wrappers around the same dependency. Replace them with one small port or direct call, then update the tests so they still assert user-visible behavior.

Reflection Question

After the change, does a reader need to open fewer files to understand the same product rule?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.