Building reusable software without trapping every feature inside the same abstraction
Most abstractions begin with a reasonable observation: we are repeating ourselves.
Imagine an application with separate services for products, customers, categories, and orders. Each service performs similar operations:
GetAll()
Save()
Search()
Implementing these methods separately creates duplication. If the API convention changes, every service may need to be updated.
A shared contract gives these services a consistent structure:
interface ICRUDService<T> {
GetAll(): Promise<T[]>;
Save(models: T[]): Promise<T[]>;
Search(query: SearchParam): Promise<T[]>;
}
PageCRUDService implements the ICRUDService contract by providing the common behaviour:
function PageCRUDService<T>(
route: string,
): ICRUDService<T> {
return {
GetAll: () =>
apiClient.get<T[]>(`${route}/GetAll`),
Save: (models) =>
apiClient.post<T[]>(`${route}/Save`, models),
Search: (query) =>
apiClient.post<T[]>(`${route}/Search`, query),
};
}
Creating a service becomes straightforward:
const ProductService =
PageCRUDService<Product>("/Product");
This is a useful abstraction. It reduces duplication, establishes a consistent API contract, and gives us one place to change shared behaviour.
The Value of a Shared Contract
Suppose every GetAll endpoint initially returns an array:
GetAll(): Promise<T[]>
Later, the API introduces server-side paging:
interface PagedResult<T> {
Data: T[];
TotalRecords: number;
Skipped: number;
Limit: number;
}
The shared contract changes to:
GetAll(
skip?: number,
limit?: number,
): Promise<PagedResult<T>>;
The implementation changes in one place:
GetAll: (skip = 0, limit = 50) =>
apiClient.get<PagedResult<T>>(
`${route}/GetAll/${skip}/${limit}`,
)
Product, customer, category, and order services do not each need a separate rewrite. They receive the new behaviour through the shared abstraction.
That is where a generic design provides real value: it centralises stable knowledge about how the application communicates with its API.
But reusable designs rarely remain simple forever.
What Happens When One Service Becomes Different?
Suppose the product service later requires operations other services do not need:
GetLowStockProducts()
UploadProductImages()
GetProductsByCategory()
One option is to keep expanding the common interface:
interface ICRUDService<T> {
GetAll(): Promise<PagedResult<T>>;
Save(models: T[]): Promise<T[]>;
GetLowStockProducts?(): Promise<T[]>;
UploadImages?(): Promise<void>;
ApproveOrder?(): Promise<void>;
}
This may work technically, but the abstraction has started losing its meaning.
Product operations become visible to customer services. Order operations become visible to category services. Most specialised methods become optional because they do not apply to most consumers.
The shared interface is no longer describing common behaviour. It is becoming a container for every possible behaviour.
Extend Through Composition
A cleaner approach is to compose the standard service with specialised behaviour:
function ProductService() {
const crud =
PageCRUDService<Product>("/Product");
return {
...crud,
GetLowStockProducts: () =>
apiClient.get<Product[]>(
"/Product/GetLowStockProducts",
),
};
}
ProductService retains the standard operations defined by ICRUDService<Product> while adding its own specialised method.
Other services remain focused:
const CategoryService =
PageCRUDService<Category>("/Category");
This leads to an important principle:
A shared abstraction should contain what its consumers genuinely have in common—not everything any consumer might eventually need.
Generic UI Components Have the Same Problem
Consider management pages that share a familiar workflow:
- Display records in a table
- Search and filter records
- Open a Create or Edit dialog
- Save changes
- Delete records
A generic management component can remove considerable duplication:
<ManagementPage
Title="Categories"
Fields={categoryFields}
Service={CategoryService}
/>
This approach works well for straightforward pages such as categories, roles, permissions, and warehouses.
Then a more complex page arrives.
A product page may require:
- Multiple categories
- Multiple images
- A primary image
- Product variants
- Warehouse-specific prices
- Stock history
- Conditional validation
- A multi-step creation process
The generic component can be expanded:
<ManagementPage
EnableImages
EnableVariants
ValidateBeforeSave={validateProduct}
RenderExtraSection={renderStockDetails}
/>
Some configuration is healthy. The danger begins when every requirement introduces another flag, callback, or special condition.
Eventually, the generic component may contain code like this:
if (entityName === "Product") {
// Product-specific behaviour
}
if (entityName === "Order") {
// Order-specific behaviour
}
At that point, the abstraction knows too much about its consumers. It becomes a central location where unrelated business rules are mixed together.
Should the Shared Abstraction Change?
That depends on whether the new requirement is common or specific.
If every service now returns paged results, update ICRUDService and PageCRUDService. That is a genuine change to the shared contract.
If only products require image management, that behaviour should not automatically become part of every service or management page.
A useful test is:
If this particular feature did not exist, would the requirement still belong in the shared abstraction?
If the answer is no, the behaviour probably belongs in specialised code or behind an extension point.
Three Ways to Handle a Growing Difference
1. Configure the abstraction
Configuration is appropriate when the difference is small and the standard workflow remains intact:
<ManagementPage
BeforeSave={validateModel}
AfterSave={refreshSummary}
/>
2. Compose smaller building blocks
When a page becomes substantially different, it can stop using the complete generic workflow while retaining smaller reusable components:
<PageHeader />
<SearchToolbar />
<DataTable />
<RightSideDialog>
<ProductForm />
</RightSideDialog>
The page owns its business logic, while the table, search toolbar, and dialog remain reusable.
3. Create a dedicated implementation
If the workflow is fundamentally different, create a dedicated page or service.
This is not a failure of the original abstraction. It means the use cases have diverged.
The mistake would be forcing every feature through the same design merely to preserve architectural uniformity.
The Default, Extension, and Escape Principle
A sustainable abstraction should provide three things.
A useful default
The common case should require very little code.
Controlled extension points
Consumers should be able to customise selected behaviour without rewriting the shared implementation.
An affordable escape route
A complex feature should be able to leave the abstraction without forcing the rest of the application to be rewritten.
This last point is often overlooked.
A design is not truly flexible simply because it accepts many options. It is flexible when a consumer can stop using it without causing architectural damage.
A Little Duplication Can Be Cheaper
Developers are often encouraged to remove duplication immediately. However, duplicated code and duplicated business concepts are not always the same thing.
Two pages may look similar today while representing workflows that will evolve independently tomorrow.
Combining them too early can create a false abstraction—one that initially saves a few lines but later requires conditions, flags, and exceptions to survive.
Sometimes two small and clear implementations are cheaper than one highly configurable generic implementation.
The goal is not to eliminate every repeated line. It is to avoid repeating stable knowledge while keeping changing business behaviour easy to understand.
Final Thought
Reusable architecture is valuable, but uniformity should not become a goal of its own.
A good abstraction standardises what is genuinely common.
A better abstraction provides controlled ways to specialise its behaviour.
A great abstraction also makes it inexpensive to walk away when the use cases are no longer the same.
The goal is not to create one design that fits everything.
The goal is to create useful defaults, honest extension points, and safe escape routes.
A good abstraction saves code today.
A great abstraction leaves room for tomorrow.
Suggested tags: Software Architecture, TypeScript, React, Clean Code, Web Development