AH
Blog

The root of all software engineering evil

architecturetech debt

TL;DR: it’s multiple sources of truth.

I have been doing this since 2016, and by now I am fairly convinced that most of the incidents worth remembering were not caused by a clever bug. They were caused by two places in the system that both claimed to know the same fact, and one day disagreed about it.

Everything else we argue about — folder structure, which framework, how many layers of abstraction — is preference dressed up as principle. This one is not preference. Duplicate a piece of truth and you have signed up for a migration later; you just don’t know the date yet.

And a migration is the lucky outcome. That is what it costs when you find the problem yourself, on your own schedule, with time to plan it. The unlucky outcome is a P0 incident — production is wrong, money or data is on the line, and the whole team stops for days while you work out which of the two copies has been lying and for how long. Same root cause. The only variable is who noticed first, you or your customers.

Why this one is worse than the others

Duplicated code is a nuisance. It is visible, greppable, and fixing it is a refactor you can do on a Tuesday afternoon.

Duplicated truth behaves completely differently. It costs nothing on the day you introduce it — both copies agree, every test passes, the feature ships. It stays free for months. Then the two copies drift, and by that point each one has its own callers, its own edge cases, and its own little population of rows that only make sense under one of the two interpretations. Now you cannot delete either without a migration, and you are doing that migration under pressure, because you found out about it from a customer.

That delay between cause and symptom is what makes it the worst one. Nothing punishes you at the moment you make the mistake.

The code should have the shape of the product

Here is the part people skip. A codebase is a model of a product. When the product changes shape, the model has to be reshaped to match — not extended sideways.

The alternative is what usually happens under deadline: you keep the old shape and bolt the new concept onto it. That is what I mean by patching. The patch always works. That is the problem.

Extending by patch

Say the product starts simple. A user has a plan.

import { pgTable, pgEnum, uuid } from "drizzle-orm/pg-core";
import { relations, eq } from "drizzle-orm";

export const planEnum = pgEnum("plan", ["free", "pro"]);
export type Plan = (typeof planEnum.enumValues)[number];

export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  plan: planEnum("plan").notNull().default("free"),
});

async function canUseFeature(userId: string) {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
  });

  return user?.plan === "pro";
}

Six months later the product grows teams, and plans now belong to the team, not the individual. Under deadline, the patch looks reasonable:

export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  plan: planEnum("plan").notNull().default("free"), // source of truth #1
  teamId: uuid("team_id").references(() => teams.id),
});

export const teams = pgTable("teams", {
  id: uuid("id").primaryKey().defaultRandom(),
  plan: planEnum("plan").notNull().default("free"), // source of truth #2
});

export const usersRelations = relations(users, ({ one }) => ({
  team: one(teams, { fields: [users.teamId], references: [teams.id] }),
}));

async function canUseFeature(userId: string) {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
    with: { team: true },
  });

  // Two columns, two answers. This precedence rule is written here — and
  // again in the billing job, and again in the admin panel.
  return (user?.team?.plan ?? user?.plan) === "pro";
}

Ship it. It works. And you have just created two sources of truth for “what plan is this person on”.

Look at what the schema now allows. users.plan is not null default 'free', so every user in a team still carries a plan column, and Postgres is perfectly happy for it to say free while their team says pro. Nothing in the database knows one of those is meant to be ignored.

Watch what happens next, because it always happens:

  • users.plan is still populated for team members. It is stale, but nothing says so. It is a perfectly valid-looking column full of lies.
  • The billing job was written before teams existed. It still reads users.plan. Nobody remembers this.
  • The feature gate reads the team. So a customer gets Pro features and a Free invoice, and you hear about it from them.
  • Every new query has to answer “which column do I read?”, and the answer lives in someone’s head.

The eventual fix is not a refactor. It is a data migration on a table with real customers in it, with a backfill you have to reason about row by row, because users.plan is right for some users and wrong for others and the schema does not record which is which.

Extending by redesign

The redesign starts from a different question. Not “where do I put teams?” but “what does the product actually mean now?” And what it means is that a plan belongs to whoever gets billed, which may be a user or a team.

That is a thing, so it gets a table:

import { pgTable, pgEnum, uuid, check } from "drizzle-orm/pg-core";
import { relations, eq, sql } from "drizzle-orm";

// Note what is missing: neither of these has a plan column any more.
export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  teamId: uuid("team_id").references(() => teams.id),
});

export const teams = pgTable("teams", {
  id: uuid("id").primaryKey().defaultRandom(),
});

// The only place a plan lives now.
export const subscriptions = pgTable(
  "subscriptions",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    plan: planEnum("plan").notNull().default("free"),
    userId: uuid("user_id").unique().references(() => users.id),
    teamId: uuid("team_id").unique().references(() => teams.id),
  },
  (t) => [
    // Exactly one owner per subscription. The database will not store an
    // ambiguous row, whatever the application layer thinks it is doing.
    check("one_owner", sql`num_nonnulls(${t.userId}, ${t.teamId}) = 1`),
  ],
);

export const usersRelations = relations(users, ({ one }) => ({
  team: one(teams, { fields: [users.teamId], references: [teams.id] }),
  subscription: one(subscriptions, {
    fields: [users.id],
    references: [subscriptions.userId],
  }),
}));

export const teamsRelations = relations(teams, ({ one }) => ({
  subscription: one(subscriptions, {
    fields: [teams.id],
    references: [subscriptions.teamId],
  }),
}));

And the read collapses into one function that nothing bypasses:

// The one place that answers "what plan is this person on".
async function planFor(userId: string): Promise<Plan> {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
    with: {
      subscription: true,
      team: { with: { subscription: true } },
    },
  });

  // Whoever gets billed owns the plan. Decided once, here.
  return (user?.team ?? user)?.subscription?.plan ?? "free";
}

async function canUseFeature(userId: string) {
  return (await planFor(userId)) === "pro";
}

Put the two side by side and the difference is not subtle:

  • plan lives in one table instead of two, so “which column do I read?” has no meaning any more. There is only one to read.
  • The database enforces it. That check constraint means an ambiguous row cannot be written at all, so the invariant survives the code paths nobody reviewed carefully and the scripts someone ran by hand at 2am.
  • There is still a branch, but it changed meaning. team ?? user is a real product rule — a team subscription supersedes a personal one — and it is resolved in exactly one function. The old team?.plan ?? user?.plan was not a rule; it was the code apologising for having two columns that disagreed.
  • canUseFeature is one line again, and every future caller gets the precedence right by construction rather than by remembering.

users.plan does not survive this. It gets migrated into subscriptions and the column is dropped, in one migration, while it is still small and while you understand it.

This is more work on the day. It is the whole game after that. Billing and feature gating now read the same value because there is only one value. There is no “which one do I read?” for the next person, and the shape of the schema matches what a colleague would tell you if you asked how the product works.

The rule I try to hold to: when the product grows a new concept, the concept goes in the model. If it goes in as an optional field and a conditional, that is usually a patch wearing a costume.

When you don’t own the sources

Sometimes you genuinely cannot have one source. The data lives in systems you don’t control and can’t merge, and no amount of good intentions changes that.

Take a Customer. In a lot of products this is split across at least two external services:

  • Stripe knows the billing side: subscription status, payment method, when they last failed to pay.
  • The CRM knows the commercial side: company name, account owner, the tier sales negotiated.

The failure mode is letting both leak into the codebase directly. The billing page talks to Stripe, the admin panel talks to the CRM, someone’s onboarding script talks to both. Each one maps the fields slightly differently. Then somebody asks whether a customer is active, and you discover the app has three answers: Stripe’s subscription.status, the CRM’s is_active flag, and a boolean somebody cached in your own database in 2024.

Nobody chose that. It accumulated.

The fix is an adapter that owns the domain, and the important word is owns:

// The type your product actually talks about.
export type Customer = {
  id: CustomerId;
  companyName: string;
  plan: Plan;
  status: "active" | "past_due" | "cancelled";
};

export interface CustomerStore {
  get(id: CustomerId): Promise<Customer>;
  rename(id: CustomerId, name: string): Promise<void>;
  changePlan(id: CustomerId, plan: Plan): Promise<void>;
}

Behind that interface, one implementation composes both services and — this is the part that matters — decides the precedence rules exactly once. Billing status always comes from Stripe. Display name always comes from the CRM. Where they overlap, the tie is broken in one file that you can open and read.

The adapter has to proxy writes as well as reads. This is where I see people stop half way: they build a nice read model, and then the admin panel writes straight to the CRM because it was quicker. Now the mapping is defined in two places again, and the read path and the write path disagree about what a rename means. If rename goes through the adapter, then the decision about which system owns the display name is made once, in the same file where reads make the same decision.

async rename(id: CustomerId, name: string) {
  // The CRM owns the display name, so that is where it goes.
  await this.crm.updateCompany(id, { name });
  // Stripe gets it too, but only so invoices read correctly.
  await this.stripe.updateCustomer(id, { name });
}

The adapter is not the source of truth because it stores the data — it stores nothing. It is the source of truth because it is the only place that decides what the data means. Everything upstream gets one answer to “who is this customer”, and when Stripe changes an API or you replace the CRM, exactly one file knows.

So

Single source of truth is not an architectural nicety you get to when the roadmap allows. It is the thing that decides whether the codebase is still workable in two years, and it is violated in small, sensible-looking increments by people under deadline, including me.

When the product changes shape, reshape the model to match. When you’re forced to aggregate systems you don’t own, put one adapter in front of them and route both reads and writes through it.

And when you catch yourself adding an optional field plus a conditional to express a genuinely new concept — that is the moment. Not later.