Independence Over Reuse

The reuse trap
Early in my career, I treated code duplication as the ultimate sin. If two things looked similar, I would extract a shared abstraction. A base class. A utility module. A generic service both domains could import.
Over the years, I have learned that this instinct -- while well-intentioned -- is one of the most expensive mistakes in software architecture. Not because reuse is bad in principle, but because premature reuse creates coupling that is invisible at first and painful later.
The principle I follow now is simple: independence over reuse. If two modules look similar but serve different business domains, I keep them separate. They can look alike today and diverge tomorrow, and when they do, neither one drags the other along.
What this looks like in practice
I will walk through real examples from a SaaS platform I built. The system has two core product features: Challenges (tasks businesses create for their customers) and Games (prize wheels, scratch cards, mystery boxes). At a glance, they look like they should share everything. Both have a name, a configuration, content blocks, a public URL, and a reward mechanism. Both need a repository, a service layer, a controller, and validation schemas.
The temptation to build a single ContentModule<T> was real. I resisted it. Here is why.
Example 1: Independent vertical slices
Each domain owns its entire vertical stack. Challenge has its own API, controller, repository, domain types, and schemas. Game has the same structure, completely separate.
back-cliencer/src/app/
challenges/
ChallengeApi.ts
controller.ts
repository.ts
domain/challenge.ts
schemas/challenge.ts
games/
GameApi.ts
controller.ts
repository.ts
domain/game.ts
schemas/game.ts
Both repositories follow the same pattern -- get by ID, get all, create, update, soft-delete:
// ChallengeRepository
async getChallengeContentById(challengeId: string): Promise<IChallengeContent | null> {
const collection = await this.getChallengeCollection();
const findResult = await collection.findOne({
_id: new ObjectId(challengeId),
archived: { $ne: true },
});
if (!findResult) return null;
return this.mapChallengeToDomain(findResult);
}
// GameRepository
async getGameById(gameId: string): Promise<IGameContent | null> {
const collection = await this.getGameCollection();
const findResult = await collection.findOne({
_id: new ObjectId(gameId),
archived: { $ne: true },
});
if (!findResult) return null;
return mapGameToDomain(findResult);
}Yes, these methods look almost identical. A textbook case for extracting a BaseRepository<T>. But look what happened when Games needed to evolve independently: the Game repository gained existsByPublicUrl() for unique URL generation, while Challenge never needed it. Game's mapper handles styleVariant, brief, ownerApproved, and prizesConfigured fields that make no sense for Challenges. Challenge's mapper handles legacy field migrations (disccountValue to discountValue) that Games never had.
If they shared a base class, every Game-specific change would require checking that it did not break Challenges. Instead, each team of one (me) could move fast in one domain without even looking at the other.
Example 2: Same concept, different shapes
Both Challenges and Games reward users. But the reward models are fundamentally different:
// Challenge: a single, flat reward structure
interface IReward {
discountValue: number | null;
discountConditions: string | null;
pointValue: number | null;
pointConditions: string | null;
giftName: string | null;
giftConditions: string | null;
generalConditions: string | null;
}
// Game: an array of probabilistic prizes
interface IPrize {
_id: string;
name: string;
probability: number;
type: PrizeType; // discount | points | gift | nothing
value: number | null;
conditions: string | null;
}A Challenge has one reward. A Game has multiple prizes, each with a probability that must sum to 100%. The Game update schema even validates this:
// Game-specific validation: prizes must sum to 100%
prizes: z.array(prizeSchema).optional().refine(
(prizes) => {
if (!prizes || prizes.length === 0) return true;
const totalProbability = prizes.reduce(
(sum, prize) => sum + prize.probability, 0
);
return Math.abs(totalProbability - 100) < 0.01;
},
{ message: "Prize probabilities must sum to exactly 100%" }
),If I had started with a shared RewardSystem abstraction, I would have built it around the Challenge model (since it came first). When Games arrived with probabilistic prizes, I would have either force-fitted the new concept into the old shape, or refactored the shared abstraction into something generic enough to handle both -- making it harder to understand for either case.
By keeping them independent, each model is exactly the shape its domain needs. No adapters. No awkward generics. No "this field is only used by Games, ignore it for Challenges."
Example 3: Duplicated schemas that diverge at the edges
Both domains validate content blocks (text, link, question, upload). The block validation schemas started identical:
// Both challenges/schemas/block-updates.ts and games/schemas/block-updates.ts
const baseBlockSchema = z.object({
type: z.enum(["text", "link", "question", "upload", "example", "birthday"]),
});
export const textBlockSchema = baseBlockSchema.extend({
type: z.literal("text"),
text: z.string().min(0, "Text is required"),
});
export const uploadBlockSchema = baseBlockSchema.extend({
type: z.literal("upload"),
uploadDescription: z.string().min(1, "Upload description is required"),
});Today, these files are nearly identical. But the Game update schema already has fields that the Challenge schema does not: styleVariant, maxAttempts, and the probability validation on prizes. When the Game domain needs stricter block validation (maybe upload blocks in games need file size limits that challenges do not), I change one file. The Challenge schema stays untouched. No risk analysis needed.
Why this matters for the business
This is not academic. Independence over reuse has concrete business consequences:
Speed of change. When I needed to add the entire prize-probability system to Games, I touched zero Challenge code. No regression testing against Challenges. No coordinating changes across two features.
Blast radius. A bug in the Game repository mapper does not affect Challenges. When I fixed the legacy disccountValue typo in Challenge's mapper, Games did not need a deployment.
Replaceability. If tomorrow I want to rewrite Games using a different database strategy or move it to a separate microservice, nothing in Challenges cares. The boundary is already clean.
Cognitive load. When I open GameApi.ts, everything I need to understand Games is right there. No jumping to a shared BaseContentApi to figure out which methods are overridden and which are inherited.
When I do share code
I am not dogmatic about this. The codebase does share some things: the IBlock type definitions live in a shared domain module because they represent a genuinely shared concept (both Challenges and Games use the same block system for content). Infrastructure like the MongoDB connection, the R2 storage client, and the logger are shared because they are not domain concepts -- they are platform capabilities.
The rule of thumb: share infrastructure, not domain logic. If it belongs to the business, keep it with its domain.
The takeaway
The next time you see two modules with similar code and feel the urge to extract a shared abstraction, ask yourself: do these things change for the same reasons? If a change in one should never require a change in the other, duplication is not the problem. Coupling would be.
Keep your modules independent. Let them look similar. The cost of a few repeated lines is nothing compared to the cost of untangling a shared abstraction that two domains have grown to depend on in incompatible ways.