Shahzad Bhatti Welcome to my ramblings and rants!

June 13, 2026

The Reusability Trap: When DRY Becomes a Liability

Filed under: Computing,Technology — admin @ 11:32 am

Reusability sounds like an obvious good practice. Write it once, use it everywhere. Don’t repeat yourself or DRY principle was popularized by The Pragmatic Programmer book. Every senior developer preaches it. But the most expensive production bugs I’ve seen didn’t come from code that was duplicated. They came from code that was shared when it shouldn’t have been. This post is about what happens when reusability becomes an obsession. I’ll show you the patterns that cause the most damage, and what to do instead. And I’ll end with a new angle that I think is underappreciated: why agentic AI coding assistants work dramatically better on well-designed, modular codebases and how the reusability trap actively makes them worse.


The Prophets Already Warned Us

The software industry has been here before. Fred Brooks warned about over-engineering in The Mythical Man-Month (1975):

“The general tendency is to over-design the second system, using all the ideas and frills that were cautiously sidetracked on the first one.”

Brooks also observed something cutting about reuse in practice: barriers to reuse sit on the consumer side, not the producer side. Yourdon estimated that reusable components require twice the effort of a one-shot component. Brooks put the multiplier at three. Parnas put it plainly:

“Reuse is something that is far easier to say than to do. Doing it requires both good design and very good documentation. Even when we see good design, which is still infrequently, we won’t see the components reused without good documentation.”

More recently, Sandi Metz landed on the same truth from a different angle:

“Duplication is far cheaper than the wrong abstraction.”

And Rob Pike, in the Go Proverbs:

“A little copying is better than a little dependency.”

These aren’t arguments against sharing code. They’re arguments against sharing code prematurely before the right abstraction reveals itself. The cost of the wrong abstraction is front-loaded with apparent savings and back-loaded with compounding debt.


Part 1: Inheritance, The Reuse That Keeps on Costing

The Promise vs. The Reality

One of pillar of object oriented languages is inheritance for reuse. Two classes share behavior? Extract a base class, done. Here’s the actual cost breakdown:

ApproachCost to CreateCost to ChangeBug Blast Radius
Duplicated code (2 copies)2× (independent)Local
Shared base class (inheritance)0.8×5–20× (understand all subclasses)Cascading
Composition1.2×1× (swap implementation)Local

The savings of inheritance are front-loaded. Every future change requires understanding the entire hierarchy. In a system with 100+ subclasses, that’s not a 20% savings, it’s a 2000% tax on every modification.

Anti-Pattern: The Fragile Base Class

I worked on a system where a senior executive was obsessed with reusability. The result was inheritance chains 10 levels deep. The worst example: a control-plane listener that inherited from a data-plane input class, just to reuse TCP socket handling.

WorkerListener --> TcpDataInput --> BaseTcpIn --> BaseInput --> BaseStatusReporter --> Serviceable --> EventEmitter

The listener’s actual job was: accept TCP connections from workers, validate auth tokens, register workers, distribute config bundles, and receive heartbeats. But it inherited an event processing pipeline it never used, IP whitelisting via regex it never used, proxy protocol support it never used, and socket idle timeouts that could kill healthy long-lived worker connections.

This nested hierarchy was a continuous source of bugs when making changes in the parent classes and broke products that inherited the unexpected changes.

The Stability Trap

There’s a design principle that explains exactly why the fragile base class is so dangerous: Stable Dependencies Principle (SDP), from Agile Software Development says:

Depend in the direction of stability. A component is stable when many things depend on it and few things it depends on can change underneath it. A component is instable when few things depend on it and it changes frequently. The principle gives you a metric for this:

I = Ce / (Ca + Ce)

Where Ca is the number of components that depend on the component (afferent couplings, things that would break if you changed it), and Ce is the number of components it depends on (efferent couplings, things that could change and break it). I = 0 means maximally stable (everyone depends on it, it depends on nothing). I = 1 means maximally instable (nothing depends on it, it depends on everything).

The SDP rule: if component A depends on component B, then B’s instability score should be lower than A’s. You should depend on things that are more stable than you are, never less. Now look at what inheritance actually does to these scores.

TcpDataInput in the example above has many consumers, it’s a shared base class used across the data plane. High Ca. That makes it look stable. But it’s also an actively maintained class that changes as data-plane requirements evolve like new connectors, security patches, protocol changes. Every change is a potential breaking change for every class that inherits it.

Inheritance creates a hidden stability inversion. The consuming class looks stable (high Ca, others depend on it), but it secretly depends on something instable (low I score from its own perspective, it changes for reasons the consumers don’t control).

This is why the principle matters beyond just “don’t change base classes carelessly.” The architecture itself needs to route dependencies in the direction of stability. Abstract interfaces are maximally stable (I = 0 by definition — they contain no implementation to change). Concrete implementations are instable. So:

  • Stable components should depend on abstract interfaces, not concrete implementations.
  • Instable components (leaf classes, frequently changing logic) should sit at the edge, depending inward toward stable abstractions.

The LSP Smell Test

Liskov Substitution Principle says: if S is a subtype of T, you should be able to substitute S anywhere T is expected without breaking anything. You’re violating LSP and inheritance is the wrong tool when you find yourself:

  • Overriding methods just to disable inherited behavior
  • Checking instanceof in calling code
  • Adding if (this instanceof ChildClass) in the parent
  • Setting this.checkDiskUsage = new NOOPDiskUsageChecker() in the constructor

I’ve seen a RingBufferOut that extended FileSystemOutput and used approximately 200 lines of it, a 5% utilization rate. It disabled disk usage checking, eliminated staging/upload separation, disabled orphan file reconciliation, and completely overrode bucket naming and retention logic. The ring buffer carried 2,700 lines of dead weight: cloud upload logic, parquet format support, staging directory management, none of which it used. The “savings” from inheritance were illusory. The dead weight made every change a minefield.

The rule of thumb: if you override more than 30% of inherited methods, or disable features in your constructor, you want composition, not inheritance.

Anti-Pattern: The Serviceable Base That Taxes Everything

A “Serviceable” base class forced EventEmitter onto 102 subclasses:

class Serviceable extends EventEmitter {
  private static INSTANCES: Serviceable[] = []; // Global tracking

  constructor(interval: number) {
    super(); // EVERY subclass is now an EventEmitter — whether it emits or not
    Serviceable.INSTANCES.push(this);
    this.serviceInterval = setInterval(() => this.service(), interval);
  }

  static destroyAll(): void {
    Serviceable.INSTANCES.forEach(s => s.destroy()); // kills everything, all at once
  }
}

// Result: 102 classes inherit this. Many NEVER emit events:
class DiskUsageReporter extends Serviceable {}  // never emits
class BackupManager extends Serviceable {}       // never emits
class HealthMonitor extends Serviceable {}       // never emits
class MetricsBatcher extends Serviceable {}      // never emits

The reasoning was: many components need a periodic timer, and EventEmitter is useful, let’s put both in a base class for reusability. The result: 102 classes carry EventEmitter’s overhead regardless of whether they ever emit a single event. Worse, the static INSTANCES array creates hidden coupling between all 102 subclasses. A destroyAll() call kills backup managers, metric batchers, and health monitors indiscriminately, no lifecycle ordering, no dependency-aware shutdown.

Fix it with composition:

// Timer is a composable utility — not an inheritance tax
class ServiceTimer {
  constructor(private callback: () => Promise<void>, private intervalMs: number) {}
  start(): void { this.handle = setInterval(() => this.callback(), this.intervalMs); }
  stop(): void { clearInterval(this.handle); }
}

class MetricsBatcher {
  private timer: ServiceTimer;

  constructor(interval: number) {
    this.timer = new ServiceTimer(() => this.flush(), interval);
  }
  // No EventEmitter. No global instance tracking. No forced API surface.
}

Each class composes only what it needs. Lifecycle is explicit. Testing is trivial.

Anti-Pattern: Depth-5 Inheritance for a Simple HTTP POST

The SaaS observability output in one system needed to POST metrics to a single endpoint with an API key and gzip compression. Reasonable enough. But it inherited from a 5-level chain:

BaseOutputter (~1K LOC) --> HTTPOut (~2K LOC) --> HTTPLoadBalancedOut (~400 lines)
  --> BatchedHTTPOut (~200 lines) --> BaseSaaSOut --> VendorMetricsOut

Total inherited before any vendor-specific code: ~4K lines. What the SaaS output actually needed: POST to one endpoint, one API key header, gzip compression, retry on 429/5xx. What it actually inherited: DNS resolution, endpoint health tracking, weighted routing, full request construction across TLS and proxy, cookie management, pipeline wiring, and backpressure signaling. Developers knew it was wrong. A TODO in production code said:

// TODO: create new class that handles multiple HTTP destinations
// instead of cascading inheritance chain

But inheritance makes fixing it prohibitively expensive. Every existing subclass depends on the hierarchy. The wrong abstraction becomes load-bearing. Fix it with a middleware stack (decorator pattern):

type HttpMiddleware = (req: HttpRequest, next: NextFn) => Promise<HttpResponse>;

const retrying: HttpMiddleware = (req, next) => retryWithBackoff(next, req, { maxRetries: 3 });
const compressing: HttpMiddleware = (req, next) =>
  next({ ...req, body: gzip(req.body), headers: { ...req.headers, 'Content-Encoding': 'gzip' }});
const authenticating = (apiKey: string): HttpMiddleware =>
  (req, next) => next({ ...req, headers: { ...req.headers, 'DD-API-KEY': apiKey }});

class SaaSMetricOutput {
  private transport: HttpTransport;

  constructor(config: SaaSOutputConfig) {
    // Build transport as middleware — no 3,350-line inheritance
    this.transport = buildTransport([
      authenticating(config.apiKey),
      compressing,
      retrying,
    ]);
  }
}

The SaaS output shrinks to ~100 lines. Adding a new vendor requires composing the right middleware, not reading a 5-level hierarchy.

Anti-Pattern: Empty Subclasses as Configuration

A system had 12 subclasses of an S3-compatible output. Seven were empty:

export class StorjS3Out extends S3Output {}        // 3 lines
export class CloudflareR2Out extends S3Output {}   // 3 lines
export class AlibabaCloudS3Out extends S3Output {} // 3 lines
// Each carries 4,500+ lines: local staging, orphan reconciliation,
// parquet writing, dead letter dirs — for cloud providers that need none of it

Each existed only for type registration in a factory map. Variant behavior is configuration, not subclasses:

const S3_PROVIDERS: Record<string, S3ProviderConfig> = {
  storj:         { pathStyle: true, region: 'global' },
  cloudflare_r2: { pathStyle: true, region: 'auto' },
  alibaba:       { pathStyle: false, endpoint: '{region}.aliyuncs.com' },
};

The Fix: Composition with Focused Interfaces

Each composed dependency has a focused interface. You can swap IWorkerAuth for mTLS without touching transport. You can test connection tracking with a fake server. A bug fix in data-plane TLS cannot reach WorkerListener.


Part 2: Cyclomatic Complexity, The Tax on Reused Code

When a class serves five different purposes, every execution path has to be guarded. When a module supports four modes, the mode checks spread like mold into every file that imports it. In one real system: 320 files contained topology checks (isLeader, isWorker, isEdge). 186 files checked feature flags deep in domain logic. 488 files accessed process.env directly. This is the direct consequence of reusing the same codebase to serve incompatible purposes.

// This pattern, scattered across hundreds of files:
if (ProcessInfo.isLeaderMode()) {
  this.startDistributedLeader();
  if (FeatureFlags.check('search-v2')) { /* ... */ }
  if (license.tier === 'enterprise') { /* ... */ }
} else if (ProcessInfo.isWorkerMode()) {
  this.connectToLeader();
  if (ProcessInfo.isRunningInCloud()) { /* ... */ }
} else if (ProcessInfo.isEdgeMode()) {
  this.startMinimalPipeline();
  if (FeatureFlags.check('edge-metrics')) { /* ... */ }
}

Every new mode requires touching 20+ files. You cannot test one mode without loading all mode code. Cyclomatic complexity of a single bootstrap method exceeds 20. Adding a deployment mode means auditing hundreds of files for hidden conditionals.

Anti-Pattern: Feature Flags as Global Conditionals

The same problem appears with feature flags. When they’re scattered inline across 186+ files, they become indistinguishable from mode checks, entitlement checks, and license checks, all mixed together:

if (FeatureFlags.check('auth-token')) {
  const { TokenStore } = require('./auth/TokenStore');
  rpc.register(new TokenStore(conf), TokenStore.ID);
}
if (FeatureFlags.check('data-insights') && Product.isWorker(mode)) { /* ... */ }
if (FeatureFlags.check('search') && license.tier === 'enterprise') { /* ... */ }

The fix is to resolve capabilities once at startup and inject them as either real implementations or no-ops:

interface ISearchCapability {
  registerEndpoints(router: Router): void;
  executeQuery(query: Query): Promise<Results>;
}

class NoOpSearch implements ISearchCapability {
  registerEndpoints(): void { /* no-op */ }
  async executeQuery(): Promise<Results> { return Results.empty(); }
}

// Resolve ONCE at startup — never scattered inline
function resolveCapabilities(flags: FeatureFlags, license: License): AppCapabilities {
  return {
    search: flags.check('search') && license.allows('search')
      ? new SearchModule(config)
      : new NoOpSearch(),
  };
}

// Boot is clean
async function boot(caps: AppCapabilities, router: Router): Promise<void> {
  caps.search.registerEndpoints(router); // dead code path simply doesn't exist
}

The Fix: Strategy Pattern + Policy Injection

Define behavior as strategy interfaces. Create one implementation per mode. Resolve the policy once at startup, everything else receives it:

class NodePolicyFactory {
  static create(role: NodeRole, license: License): NodePolicy {
    // THIS is the ONLY place that mode-switches
    switch (role) {
      case 'leader': return {
        processing: { maxWorkers: 0, enableSearch: true },
        behavior: new LeaderBehavior(),
      };
      case 'edge': return {
        processing: { maxWorkers: 1, maxHeapMB: 512, enableSearch: false },
        behavior: new EdgeBehavior(),
      };
    }
  }
}

// All other code receives the policy — zero mode checks
class PipelineEngine {
  constructor(private policy: NodePolicy) {}
  async start(): Promise<void> {
    const workerCount = this.policy.processing.maxWorkers; // no if-else
  }
}

Runtime complexity goes from O(modes × flags × tiers) to O(1).


Part 3: The God Class, Reuse at the Wrong Granularity

When developers try to build a “reusable” class that serves many purposes, they often produce a God Class where a single class that does everything so it can serve everyone. One system had classes like:

FileLinesResponsibilities
ApplicationServer~2K LOCBootstrap, mode detection, process spawning, metrics, REST startup, shutdown
FileSystemOutput~3K LOCStaging, upload, cleanup, metrics, parquet, reconciliation
ProcessManager~1.5K LOCProcess lifecycle, metrics init, license, git, config helpers, warm pool
HttpBaseInput~2K LOCHTTP server, TLS, health, auth, parsing, compression, routing, proxy
RemoteConnection~2,5K LOCWorker lifecycle, config push, metrics, commands, upgrades

The problem isn’t the line count. It’s that every responsibility changes for different reasons at different times. When the metrics subsystem needs a change, you’re editing the same file that controls TLS configuration. When a new output format is added, you’re touching the same class that manages staging directories.

HttpBaseInput is a good example of the architectural layer problem. It mixed transport (TCP socket management, TLS), protocol (NDJSON parsing, compression), authentication (token validation, auth state machine), application logic (field extraction, time parsing), metrics (request counts, latency histograms), and load balancing, all in one class. Every HTTP-based input (Splunk HEC, OTLP, Elastic, Datadog) inherited all ~2K lines. Changing the TLS configuration risked disrupting field extraction. Adding a health endpoint risked breaking authentication middleware. Fix it by separating layers:

// Each layer is independent — compose at construction time
class SplunkHecInput {
  constructor(
    private transport: IHttpServer,        // Layer 1: socket, TLS
    private auth: IAuthenticator,          // Layer 2: token validation
    private protocol: ISplunkHecParser,    // Layer 3: /services/collector format
    private pipeline: IEventSink,          // Layer 4: deliver events downstream
    private metrics: IInputMetrics,        // Cross-cutting: counters, latency
  ) {}
}
// Changing TLS (transport) cannot break Splunk parsing (protocol)
// Testing protocol parsing requires NO HTTP server — just pass mock events

Part 4: Missing Layers, REST Endpoints Doing Direct I/O

Here’s a less obvious form of the same problem. REST handlers that reach directly into the filesystem:

class AppsEndpoint {
  async handlePut(req: Request): Promise<Response> {
    await writeFile(targetPath, req.body);          // direct fs
    await mkdir(artifactDir, { recursive: true });
    const files = await readdir(configDir);
    // No abstraction, no transaction, no testability
  }
}

This prevents swapping storage backends, adding transaction semantics, unit testing without filesystem mocks, and centralized corruption detection. The application layer reached through the persistence layer, a layer violation that makes both layers impossible to change independently. The fix is a persistence abstraction:

interface IConfigStore {
  read(path: ConfigPath): Promise<Buffer>;
  write(path: ConfigPath, data: Buffer): Promise<void>;
  transaction<T>(fn: (tx: IConfigTransaction) => Promise<T>): Promise<T>;
}

class AppsEndpoint {
  constructor(private store: IConfigStore) {}

  async handlePut(req: Request): Promise<Response> {
    await this.store.transaction(async (tx) => {
      await tx.write(targetPath, req.body);
      await tx.write(metadataPath, metadata);
      // Atomic: both succeed or both roll back
    });
  }
}

Part 5: CRUD as Architecture, Generic APIs That Serve Nobody

CRUD generators are another form of pathological reuse. One model, one handler, one UI pattern for everything. They deliver APIs optimized for the database schema rather than user intent.

// "Reusable" CRUD generator applied to 40+ resources
createCrudEndpoints('workers', workerSchema, workerStore);
createCrudEndpoints('pipelines', pipelineSchema, pipelineStore);

// PUT /workers/:id demands ALL 10 fields, even though:
//   "Rename a worker" only needs { description }
//   "Move to a group" only needs { group }
//   "Scale up" only needs { maxProcesses, heapSizeMB }

Callers must research which fields matter for their specific operation. Concurrent callers doing GET –> modify one field –> PUT back create race conditions. The fix models what users actually do, not what the database stores:


Part 6: npm and the Dependency Chain Problem

Inheritance abuse at the code level has a direct analog at the package level. I used PERL’s CPAN extensively in the 1990s with the Mason web templating system. It worked beautifully until it didn’t. Then came Maven, pip, npm, RubyGems, Cargo. Each language built its own package ecosystem. Each package could depend on other packages, creating dependency trees that look like fractals. We never developed mature patterns for managing these at scale. The npm ecosystem exemplifies the chaos. In 2016, a developer unpublished left-pad, an 11-line function that padded strings with spaces. Thousands of projects broke overnight. Babel, React, and countless applications depended on it through layers of transitive dependencies. This pattern repeats. I’ve seen production applications import packages for:

  • is-odd / is-even: check if a number is odd (n % 2 === 1)
  • is-array: check array type (JavaScript has Array.isArray() built-in)
  • string-split: split text

The MIT Sloan Management Review and ACM both document the risks of software reuse at scale. The core finding: reuse shifts risk from “building the wrong thing” to “inheriting the wrong dependency chain.” A single Go project might pull in hundreds of transitive dependencies, each a potential security vulnerability. Both costs are real. Only the first one gets measured.


Part 7: Reusing Security Tokens, The Shared Blast Radius

The most dangerous form of reuse isn’t in code. It’s in credentials.

class InstanceSettings {
  // One token — shared by every worker in the fleet of thousands
  authToken: string = crypto.randomBytes(16).toString('hex');
}

if (req.headers['x-auth-token'] !== this.authToken) {
  return res.status(401).json({ error: 'Unauthorized' });
}

A single compromised worker exposes every worker. Revoking one worker’s access requires rotating the shared secret for the entire fleet, a coordinated operation that takes the whole fleet offline simultaneously. In one system, we shared same token between the control plane and the data plane for euse optimization. This caused innumerable bugs when control plane changed its token scheme from opaque tokens to JWT. The fix is per-identity tokens with short TTLs:

class WorkerTokenIssuer {
  async issueToken(identity: WorkerIdentity): Promise<AccessToken> {
    return this.mint({
      sub: identity.clientId,           // unique per worker
      scopes: identity.scopes,           // minimal privilege
      exp: Date.now() + this.tokenTTLMs, // short-lived
      jti: ulid(),                        // unique — enables revocation
    });
  }

  async revokeWorker(clientId: string): Promise<void> {
    await this.revocationList.add(clientId);
    // Other 9,999 workers unaffected
  }
}

Every system managing thousands of agents at scale like Datadog, Prometheus exporters, Kubernetes kubelets issues per-agent certificates or short-lived tokens. Shared credentials aren’t a cost saving. They’re a single blast radius for your entire fleet.


Part 8: Shared Modules, How Common Code Slows Teams

Shared or “common” modules feel like the right call. One place for utilities, helpers, shared models. Every team uses the same battle-tested code. No duplication. In practice, these modules become the most contested real estate in the codebase.

Team A needs a small change to a shared validation function. They open a PR. But the common module is owned by a platform team that maintains a release cadence. Team A waits for the next release window. Team B is blocked on a different change to the same file. Both PRs conflict. The platform team spends a sprint mediating merge conflicts they didn’t create. I’ve seen this pattern repeat at multiple companies:

  • A common module starts as a home for genuinely shared utilities, timestamp parsing, config validation, ID generation.
  • Teams start adding features to it because “it’s already shared.” Team A adds a flag to change behavior for their use case. Team B adds a different flag. The module grows a conditional for every team’s edge case.
  • The module that was supposed to prevent duplication becomes the largest source of complexity, merge conflicts, and broken builds in the codebase.

Brooks identified the organizational dimension of this in The Mythical Man-Month: corporate-level reuse “implies changes in project accounting and measurement practices to give credit for reusability.” Teams get credit for shipping features, not for investing in shared infrastructure. The incentives push toward adding to common quickly, and away from the expensive work of designing a proper stable interface. The result is that common gets additions but rarely deletions, refinements, or principled breaking changes.

What works instead:

  • Narrow, stable libraries: utilities with pure functions (parseTimestamp, generateId), no state, no side effects. These can be shared safely because they have no behavior to conflict over.
  • Published interfaces, not shared implementations: agree on the contract, let each team implement. If two teams share an interface rather than a class, their implementations evolve independently.
  • Internal packages with semantic versioning: treat shared code like a real library. Pin versions per team. Break changes intentionally and explicitly. Don’t silently couple release trains.
  • Copy for divergence: if Team A and Team B both need slightly different behavior from a shared function, copy it. Let each version evolve toward its actual use case. The right abstraction will reveal itself only after divergence, not before.

Part 9: The Monolithic Binary, Inheritance Made Physical

Inheritance abuse has a physical consequence: it makes separation architecturally impossible. When WorkerListener extends TcpDataInput, you cannot compile WorkerListener without the entire data-plane input hierarchy. You cannot deploy the leader without bundling all input connector code. When HeartbeatSender extends TcpSender, you cannot deploy a worker without bundling all output connector code. The result in one system: a single binary exceeding 200MB containing all modes, all 150 connectors, and all feature code, regardless of which node role deployed it.

SystemArchitectureAgent Size
Monolithic inheritance systemSingle binary, all modes200–400MB
Datadog AgentGo binary, plugin-based~50MB
Fluent BitC binary, plugin-based10–30MB
VectorRust binary, feature-flagged30–50MB
TelegrafGo binary, registry pattern~60MB

The inheritance chain creates a compile-time dependency graph that makes separation physically impossible even if you wanted a “leader-only” binary, the import chain through inheritance pulls in every connector. Competitors use composition-based plugin architectures from the start:

// Telegraf: no class inherits from another — each plugin is independent
func init() {
    inputs.Add("kafka", func() telegraf.Input { return &KafkaInput{} })
}
// Adding a plugin: add one file. No core file modified.
// Building a minimal binary: don't compile that file.

Each module declares its activation events. The kernel loads only modules matching the current role and entitlements. A bug in the Kafka connector cannot affect S3. Adding a connector requires zero changes to core.


Part 10: Shared Mutable State, The Singleton Tax

In one system, we had 474 singletons. That’s how many I counted in one codebase.

Configuration.instance().loadSystem('app');
GitMgr.instance().ignore();
AuthTokenAuthority.instance().createToken(claims);
InputMgr.instance().getInput(id);
// ... 20+ more

Every singleton creates invisible coupling: any code can access any singleton without declaring the dependency. Creation and destruction order is undefined. Tests cannot provide mocks without manipulating global state. Request-scoped, group-scoped, and process-scoped data all use the same pattern. Module-level mutable state is the same problem in a different form. One system had 30+ pipeline functions with module-level variables:

let _primaryCache = new Map();
let _numEventsReceived = 0;

exports.process = (event) => {
  const key = _expression.evalOn(event);
  _primaryCache.get(key).count++;  // global mutation in hot path
  _numEventsReceived++;
};

There’s no isolation between pipeline instances sharing the same module, and race conditions emerge the moment processing is parallelized. The fix is closure-encapsulated state — state is local to the instance, not the module:

function createProcessor(config: ProcessorConfig): Processor {
  let primaryCache = new Map<string, CacheEntry>(); // local to THIS instance

  return {
    process(event) {
      const key = config.keyExpr.evalOn(event);
      const entry = primaryCache.get(key) ?? createEntry();
      entry.count++;
      return entry.count <= config.maxToAllow ? event : null;
    },
  };
}

const processor1 = createProcessor(config1);
const processor2 = createProcessor(config2); // completely independent

Part 11: The New Angle, Agentic AI Thrives on Modular Code

Here’s something I’ve observed that doesn’t get written about enough: the quality of AI-generated code degrades sharply with the complexity of the codebase it works in.

Agentic coding tools like Claude Code, Cursor, Copilot in agent mode, and others are transformative for well-structured codebases. But point them at a codebase with deep inheritance hierarchies, scattered conditional logic, god classes, and shared mutable singletons, and the output becomes unreliable in predictable ways.

Why Bad Structure Amplifies AI Mistakes

  • Context window exhaustion. When a class inherits from a 7-level hierarchy, understanding what any method does requires reading across 3+ directories and thousands of lines. AI tools have a finite context window. A god class of 2,000+ lines, a shared common module with hundreds of exports, or a deep inheritance tree consumes that window before the model even reaches the code it’s supposed to change. The model ends up reasoning from partial context and partial context produces confident-looking but wrong code.
  • Conditional logic compounds errors. When 320 files contain mode checks and 186 contain scattered feature flag conditionals, the model has to track implicit state through the entire call graph to reason correctly about any change. Every missed conditional is a latent bug. I’ve seen AI agents introduce a change that was correct for isLeaderMode() but silently wrong for isEdgeMode()because the conditional branching was too diffuse to track reliably.
  • Inheritance hierarchies hide side effects. When a model generates code for a leaf class in a deep hierarchy, it may not realize that super.init() triggers a chain of side effects through five parent classes, or that overriding getTimeout() will be called in 12 different contexts. The model sees the method signature. It doesn’t see the full inheritance contract. The result looks plausible but breaks at runtime.
  • Shared mutable state creates invisible dependencies. A model generating a new component might not know that a singleton it touches is also modified by three other components during the same request lifecycle. In a clean dependency-injected system, those dependencies are declared. In a singleton-heavy system, they’re invisible and invisible dependencies produce bugs that are hard to reproduce and harder to explain to an AI that’s trying to help you fix them.

What AI Agents Do Well and Where Structure Helps

The pattern I keep seeing: AI agents work best when they can work on one focused thing at a time. A well-designed system with:

  • Small classes with single responsibilities
  • Explicit interfaces and dependency injection
  • Focused modules with clear boundaries
  • No cross-domain inheritance
  • Composition over inheritance throughout

The cleanest formulation I’ve found: the codebases that benefit most from AI-assisted development are exactly the codebases that already practice good design.


The Decision Framework

MechanismSafe WhenDangerous When
Copy-paste2–3 instances, likely to divergeNever
Shared utility functionPure logic, no state, no side effectsWhen it accumulates parameters to serve all callers
Shared interfaceMultiple implementations of same contractWhen the interface grows to satisfy one implementation
CompositionReusing behavior across unrelated concernsAlmost never dangerous
InheritanceTrue “is-a”, LSP holds, < 30% overrideDifferent domains, constructor disabling, >30% override
Common moduleStable, narrow, pure utilitiesAnything with mutable behavior, ownership ambiguity
CRUD generatorSimple reference dataResources with distinct business operations
Shared config/tokenNeverAlways

Conclusion: Duplication You Can See vs. Coupling You Can’t

The drive for reusability is real. Duplicated logic is a real cost. But the engineers who warn against premature abstraction like Brooks, Metz, Pike, Beck, Parnas are pointing at something specific: coupling is invisible at creation time and expensive at change time. Duplicated code can be changed independently. The wrong abstraction propagates changes to every consumer. A shared inheritance hierarchy means a security fix in the control plane can take down the data plane. A shared token means one compromised worker compromises the fleet. A shared common module becomes the shared surface for every team’s bugs and merge conflicts.

And now there’s a new dimension to this: a well-structured, modular codebase with clear boundaries and composition over inheritance is also the codebase where AI agents work reliably. The investment in clean design pays dividends across every developer you add whether human or AI.

The safest question to ask before sharing anything: what happens when this needs to change? If the answer is “nothing else breaks,” share it. If the answer is “everything that depends on it,” think harder about whether you’re creating an abstraction or a trap. Start with duplication. Let the right abstraction reveal itself. Then share via composition, narrow interfaces, and well-bounded modules. The cost of the wrong abstraction always exceeds the cost of a little repetition.


No Comments

No comments yet.

RSS feed for comments on this post. TrackBack URL

Sorry, the comment form is closed at this time.

Powered by WordPress