Pocock Skills
Architecture

Improve Codebase Architecture

Systematic architecture improvement: build the vocabulary, map the terrain, design competing interfaces, then test at seams.

Full Glossary

These eight terms are the shared vocabulary for every architecture improvement conversation. Internalize them before beginning any analysis.

Core Architecture Vocabulary

Module

A cohesive unit of code that encapsulates a single responsibility. A module has a public interface and hides its implementation. The boundary of a module is defined by what it exports, not by what directory it lives in.

Avoid: package, folder, file, component (when used loosely)

Interface

The contract through which a module is used. An interface defines inputs, outputs, and behavior guarantees without specifying implementation. In dynamically typed languages, the interface may be implicit (a duck-typed protocol) rather than explicit (a TypeScript interface or Go interface).

Avoid: API (overloaded), class (too narrow), type (too narrow)

Implementation

The code inside a module that fulfills the interface contract. Implementations are interchangeable as long as they satisfy the same interface. The goal of architecture improvement is often to make implementations truly interchangeable.

Avoid: internals, guts, logic, code

Depth

The ratio of interface complexity to implementation complexity. A deep module has a simple interface that hides a complex implementation — high leverage. A shallow module has an interface nearly as complex as its implementation — low leverage, often a sign of poor abstraction.

Avoid: abstraction level (vague), layer (implies hierarchy)

Seam

A place in the code where behavior can be changed without modifying the code on either side of the seam. Seams are testing surfaces. Every dependency injection point, interface, and function parameter is a potential seam.

Avoid: injection point (too narrow), hook (overloaded), extension point (implies plugin architecture)

Adapter

A module that translates between two interfaces. An adapter wraps a specific implementation behind a general interface. Adapters enable implementation substitution at seams. A single adapter is a design hypothesis; two adapters with one interface is evidence of a real abstraction.

Avoid: wrapper (less precise), proxy (implies network), facade (implies simplification only)

Leverage

The ratio of behavior change enabled to code changed. High-leverage code changes (modifying an interface) affect all users of that interface. Low-leverage code changes (adding a null check inside one function) affect only that function.

Avoid: impact (vague), scope (vague)

Locality

The degree to which code that changes together lives together. High locality means you can make a coherent change to one area of the codebase without touching many unrelated files. Low locality is the root cause of 'shotgun surgery' — a single logical change requiring edits in many places.

Avoid: cohesion (academic), coupling (the inverse concept, not the same thing)

Key Principles

The deletion test

Before proposing any abstraction, ask: “What would happen if we deleted this and called the underlying thing directly?”

  • If nothing would be harder, the abstraction adds no value and should be deleted.
  • If deletion would require duplicating logic in 3+ places, the abstraction has earned its existence.
  • If deletion would expose callers to implementation details they cannot be expected to know, the abstraction is earning depth.
Run the deletion test on every module you encounter during exploration. It is the fastest way to distinguish essential complexity from incidental complexity.
Interface is the test surface

The interface, not the implementation, determines what can be tested. A well-designed interface produces tests that are:

  • Independent of implementation details — they test behavior, not structure.
  • Stable across refactors — they do not need to change when the implementation changes.
  • Composable — they test components at the right level of abstraction, not too high (no behavior coverage) and not too low (implementation-coupled tests that break on refactors).
// Bad: test coupled to implementation
test("uses Redis to cache the result", () => {
  const spy = jest.spyOn(redisClient, 'set');
  getUserById(123);
  expect(spy).toHaveBeenCalled();
});

// Good: test at the interface
test("returns the same user object on repeated calls", async () => {
  const first = await getUserById(123);
  const second = await getUserById(123);
  expect(first).toEqual(second);
  // Does not care whether Redis, memory, or no cache is used
});
One adapter = hypothesis, two adapters = real abstraction

When you create one adapter, you are making a prediction: “this interface will be useful with a different implementation.” This is a hypothesis.

When you create a second adapter with the same interface, the hypothesis is confirmed. Two independent implementations that satisfy the same interface provides evidence that the interface is real and valuable.

Do not generalize an interface until you have two concrete implementations. Premature generalization produces interfaces that fit neither implementation well.
// One adapter — hypothesis only:
interface CacheService {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttl: number): Promise<void>;
}

class RedisCache implements CacheService { ... }
// At this point, CacheService is a hypothesis.
// You may never need another implementation.

// Two adapters — real abstraction confirmed:
class RedisCache implements CacheService { ... }
class MemoryCache implements CacheService { ... }
// Now the interface is validated. Tests can use MemoryCache,
// production can use RedisCache, and the interface is real.

Process: Explore → HTML Report → Grilling Loop → Interface Design

Step 1: Explore

Before proposing any changes, explore the codebase to build a map of the current architecture. Use these exploration techniques:

  • Trace the path of one core user operation from entry point to storage. What modules does it touch?
  • Find the largest files. Large files are evidence of missing module boundaries.
  • Find the most-imported modules. Highly connected modules are leverage points.
  • Find the test coverage gaps. What code has no tests? These are likely areas of high implementation-coupling.
  • Find the dependency graph. What depends on what? Are there cycles?

The output of exploration is a map of: core modules, their dependencies, their depth scores, and the highest-leverage seams for improvement.

Step 2: HTML Exploration Report

Generate an HTML report of the exploration findings. This report is the shared artifact that drives the grilling loop. It is NOT a presentation — it is a working document.

HTML report format:

  • Styled with Tailwind CSS (no external dependencies).
  • Uses Mermaid.js for dependency graphs (loaded from CDN).
  • Candidate cards: one card per candidate module for improvement.
  • Each card shows: current interface, before/after comparison, why it is a candidate, what depth score it has.
<!DOCTYPE html>
<html lang="en">
<head>
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
  <script src="https://cdn.tailwindcss.com"></script>
  <title>Architecture Exploration — [Project Name]</title>
</head>
<body class="bg-gray-50 font-sans p-8">

  <!-- Summary -->
  <h1 class="text-2xl font-bold mb-2">Architecture Exploration</h1>
  <p class="text-gray-600 mb-8">Generated: [date]</p>

  <!-- Dependency graph -->
  <section class="bg-white rounded-xl border p-6 mb-6">
    <h2 class="font-semibold mb-4">Dependency Graph</h2>
    <div class="mermaid">
      graph TD
        A[UserRouter] --> B[UserService]
        B --> C[UserRepository]
        B --> D[CacheService]
        C --> E[Postgres]
        D --> F[Redis]
    </div>
  </section>

  <!-- Candidate card -->
  <section class="bg-white rounded-xl border p-6 mb-4">
    <div class="flex items-start justify-between">
      <h3 class="font-semibold text-lg">CacheService</h3>
      <span class="bg-yellow-100 text-yellow-800 text-xs px-2 py-1 rounded-full">
        Candidate
      </span>
    </div>
    <p class="text-sm text-gray-600 mt-1">
      The cache is directly imported by 7 modules. Redis is
      the only implementation. Adding MemoryCache for tests
      requires changes in 7 places.
    </p>
    <!-- Before/after would be shown here -->
  </section>

</body>
</html>
Step 3: Grilling Loop

Share the HTML report with the domain expert. For each candidate card, run a grilling session:

  1. 1Present the current interface and the identified problem (low depth, poor locality, etc.).
  2. 2Ask: 'Is my characterization of the problem correct, or am I missing context?'
  3. 3Ask: 'Are there constraints that make the obvious improvement infeasible?'
  4. 4Ask: 'Have you tried to improve this before? What happened?'
  5. 5Capture decisions as ADRs if they satisfy the three-gate rule.
Step 4: Interface Design with Parallel Agents

For each module selected for improvement, spawn three or more parallel agents, each with different constraints. The goal is competing designs, not consensus.

  • Agent A: optimize for the smallest possible interface (fewest exported symbols).
  • Agent B: optimize for the most testable interface (easiest to mock/stub at the seam).
  • Agent C: optimize for backwards compatibility (existing callers need zero changes).
  • Optional Agent D: optimize for the most explicit error handling.

Why parallel and competing?

A single agent designing an interface will unconsciously bias toward the implementation it already knows. Competing constraints force genuinely different designs. The best design often emerges by taking different aspects from different agents.

Dependency Categories

Every dependency in a codebase falls into one of four categories. Each category has a different testing and improvement strategy.

CategoryDefinitionTest strategyImprovement strategy
In-processAnother module in the same process. Imported directly.Unit test at the interface. No mocking needed if the module is fast and deterministic.Apply the deletion test. Introduce a seam if two implementations exist.
Local-substitutableAn external dependency that can be replaced with a local version for tests (in-memory database, file system, etc.).Use the local substitute in tests. The real dependency in integration tests.Ensure a clear interface exists. Do not let callers use the real and substitute implementations interchangeably in production.
Remote-but-ownedAnother service we own and operate. We control both ends of the connection.Contract tests: both producer and consumer own a shared contract test suite.Define a client interface. One adapter per client instance. Test with the contract.
True externalA third-party service we do not control (Stripe, SendGrid, AWS, etc.).Mock at the adapter boundary only. Never mock the true external service inside business logic.Create an adapter that wraps the external SDK. The business logic depends on the adapter interface, not the SDK.

Seam Discipline and Testing Strategy

A seam is a point where you can change behavior without modifying code on either side. Every seam is a test surface.

Seam placement rules
  • Place seams at dependency boundaries, not inside business logic. Business logic should not know that a seam exists.
  • A seam is most useful when the two sides have different test requirements. The classic case: business logic (test with fast unit tests) and I/O (test with integration tests).
  • Do not introduce a seam speculatively. Only introduce a seam when you need to substitute behavior in tests or have two real implementations.
  • Seams that are never used in tests are not seams — they are just indirection with no payoff.
Testing at the correct seam

Test each module at its highest useful seam — the seam that covers the most behavior with the fewest test dependencies.

// Three possible test seams for a user registration flow:

// Seam 1 (too low — implementation coupled):
test("registerUser calls bcrypt.hash with cost factor 12", ...)

// Seam 2 (correct — tests behavior through the interface):
test("registerUser returns a user with a hashed password", ...)
// Does not care which algorithm, what cost factor

// Seam 3 (too high — hides too much detail):
test("the entire registration endpoint returns 201", ...)
// Covers too many failure modes in one test
The correct seam is usually one level above the implementation you want to change. This keeps tests stable across implementation changes while still providing meaningful behavioral coverage.
Seam inventory

Maintain a seam inventory as part of the architecture report. For each major module, list its seams, what behavior each seam tests, and whether each seam is currently tested.

ModuleSeamWhat it testsCurrently tested?
UserServiceCacheService interfaceCache hit/miss behaviorNo — Redis is always used
UserServiceUserRepository interfaceStorage read/write behaviorPartial — only happy path
OrderProcessorPaymentGateway interfacePayment success/failure pathsYes — MockPaymentGateway used