万能设计——直到它不再万能
追求可复用软件是一项崇高的事业。它承诺效率、可维护性,以及跨复杂系统的统一愿景。但正如任何花费时间完善抽象概念的人所知,从"一个合理的观察"到真正通用的设计之路往往充满挑战。初始的简单性很快会转变为僵化的结构,扼杀创新并迫使开发人员做出笨拙的妥协。这种二元性正是"万能设计"悖论的核心。
抽象的魅力
通常,旅程始于一个简单的真理:我们在重复自己。在一个典型的电商应用中,我们可能有独立的产品、客户、分类和订单服务。虽然每个服务在其领域内独特,但它们通常执行相似的操作。考虑以下TypeScript中假设的ProductService代码片段:
interface ProductService {
getAll(): Product[];
getById(id: string): Product | null;
create(product: Product): Product;
update(id: string, updates: Partial<Product>): Product;
delete(id: string): void;
}
这种模式在许多领域都很熟悉。CustomerService会镜像这些操作,可能带有细微变化,如处理地址或联系信息。初始抽象看似显而易见:创建一个封装这些通用模式的通用CRUDService。这感觉像是个胜利——代码更少,组织更好,维护更方便。
过度泛化的陷阱
问题在于当我们试图将所有用例都塞进这个单一抽象时出现。CRUDService最初是一个干净的起点:
abstract class CRUDService<T> {
abstract getAll(): T[];
abstract getById(id: string): T | null;
abstract create(data: Partial<T>): T;
abstract update(id: string, updates: Partial<T>): T;
abstract delete(id: string): void;
}
一开始,这似乎很完美。现在我们可以将ProductService定义为具体实现:
class ProductService extends CRUDService<Product> {
constructor(private repo: ProductRepository) {
super();
}
getAll(): Product[] {
return this.repo.findAll();
}
getById(id: string): Product | null {
return this.repo.findById(id);
}
create(product: Product): Product {
return this.repo.save(product);
}
update(id: string, updates: Partial<Product>): Product {
return this.repo.update(id, updates);
}
delete(id: string): void {
this.repo.delete(id);
}
}
这可行!但随着我们引入更多复杂性,抽象开始出现裂痕。当我们的某个服务需要其他服务没有的功能时会发生什么?也许OrderService需要实时验证,而ProductService不需要。或者,CustomerService需要处理客户创建后发送邮件的异步后台任务。
试图将这些不同的需求塞进同一个CRUDService抽象很快会变成噩梦。我们引入可选参数、回调和一大堆if/else语句,使代码变得臃肿且难以理解。曾经简洁明了的抽象,现在感觉像紧身衣。
粒度化设计的路径
关键洞察在于认识到并非所有功能都同等重要。有些模式确实是通用的,而有些则是领域特定的。解决方案不是完全抛弃抽象,而是要更谨慎地选择我们想要泛化的内容。
1. 确定核心模式
首先,确定真正通用的模式,这些模式很可能在许多服务中共享。在大多数CRUD操作中,getById、getAll以及基本的create、update、delete可能普遍适用。这些可以构成核心抽象的基础。
2. 允许扩展
对于更专业的功能,使用继承和组合来允许服务在不需要额外功能的情况下扩展核心。TypeScript的abstract class非常适合此目的:
abstract class BaseCRUDService<T> {
abstract getAll(): T[];
abstract getById(id: string): T | null;
abstract create(data: Partial<T>): T;
abstract update(id: string, updates: Partial<T>): T;
abstract delete(id: string): void;
}
class ProductService extends BaseCRUDService<Product> {
// 核心CRUD实现
}
class OrderService extends BaseCRUDService<Order> {
async validateOrder(order: Order): Promise<boolean> {
// 订单特定验证逻辑
return true;
}
}
3. 利用混入实现可选功能
对于可选但可能在多个服务中有用的功能,考虑使用混入或实用函数。这使核心抽象保持精简,同时允许服务按需选择所需功能。
function withValidation<T extends BaseCRUDService<any>>(service: T): T & {
validate(data: T['T']): boolean;
} {
return {
...service,
validate(data: T['T']) {
// 验证逻辑
return true;
},
} as any;
}
class AdvancedProductService extends withValidation(ProductService) {
// 继承CRUD和验证
}
4. 保持抽象聚焦
抽象的黄金法则是YAGNI(你不会需要它)。如果一个功能没有明确被多个服务需要,就不要将其添加到抽象中。过度泛化是创建僵化结构并使其成为维护负担的最快方式。
真实案例:电商平台
让我们重新审视我们的电商平台。最初,我们有重复CRUD逻辑的独立服务。通过重构,我们实现了更清晰的架构:
// 核心抽象
abstract class BaseCRUDService<T> {
// ... 标准CRUD方法
}
// 领域特定服务
class ProductService extends BaseCRUDService<Product> {
// 产品特定逻辑
}
class OrderService extends BaseCRUDService<Order> {
// 订单特定逻辑
validateOrder(order: Order): boolean {
// 实时验证
return true;
}
}
// 使用混入实现可选功能
function withEmailNotifications<T extends BaseCRUDService<any>>(service: T): T & {
sendCreationEmail(data: T['T']): void;
} {
return {
...service,
sendCreationEmail(data: T['T']) {
// 发送邮件逻辑
},
} as any;
}
class NotifiedCustomerService extends withEmailNotifications(CustomerService) {
// 继承CRUD和邮件通知
}
这种结构使核心CRUDService保持专注,同时允许领域特定服务按需扩展。withEmailNotifications混入提供了可选功能,可应用于需要它的任何服务。
粒度的代价
当然,这种方法并非没有权衡。粒度化设计可能导致代码重复增加,因为每个服务都需要定义核心模式的实现。如果管理不当,还可能存在不一致性。但这些成本通常会被灵活性和可维护性的好处所抵消。
总结
"万能设计"方法是一个诱人的起点,但很少是最终目的地。最成功的软件系统在泛化和专业化之间取得了平衡。通过识别核心模式、允许扩展并牢记YAGNI,我们可以创建既可复用又可适应的抽象。关键不是过度简化,而是以细致入微的方式设计,认识到并非所有功能都同等重要——而这完全可以接受。
One Design Fits All—Until It Doesn’t
The pursuit of reusable software is a noble one. It promises efficiency, maintainability, and a unified vision across complex systems. But as anyone who’s spent time refining an abstraction knows, the path from "a reasonable observation" to a truly versatile design is often fraught with challenges. The initial simplicity can quickly give way to a rigid structure that stifles innovation and forces developers into awkward compromises. This duality is the heart of the "one design fits all" paradox.
The Allure of Abstraction
The journey typically begins with a simple truth: we are repeating ourselves. In a typical e-commerce application, we might have separate services for products, customers, categories, and orders. Each service, while unique in its domain, often performs similar operations. Consider the following TypeScript snippet for a hypothetical ProductService:
interface ProductService {
getAll(): Product[];
getById(id: string): Product | null;
create(product: Product): Product;
update(id: string, updates: Partial<Product>): Product;
delete(id: string): void;
}
This pattern is familiar across many domains. A CustomerService would mirror these operations, perhaps with slight variations like handling addresses or contact information. The initial abstraction seems obvious: create a generic CRUDService that encapsulates these common patterns. This feels like a win—less code, better organization, easier maintenance.
The Trap of Overgeneralization
The problem arises when we try to fit every use case into this single abstraction. The CRUDService starts as a clean slate:
abstract class CRUDService<T> {
abstract getAll(): T[];
abstract getById(id: string): T | null;
abstract create(data: Partial<T>): T;
abstract update(id: string, updates: Partial<T>): T;
abstract delete(id: string): void;
}
At first, this seems perfect. We can now define ProductService as a concrete implementation:
class ProductService extends CRUDService<Product> {
constructor(private repo: ProductRepository) {
super();
}
getAll(): Product[] {
return this.repo.findAll();
}
getById(id: string): Product | null {
return this.repo.findById(id);
}
create(product: Product): Product {
return this.repo.save(product);
}
update(id: string, updates: Partial<Product>): Product {
return this.repo.update(id, updates);
}
delete(id: string): void {
this.repo.delete(id);
}
}
This works! But as we introduce more complexity, the abstraction starts to fray. What happens when one of our services needs a feature the others don’t? Maybe the OrderService requires real-time validation, while the ProductService doesn’t. Or perhaps the CustomerService needs to handle asynchronous background jobs for sending emails after a customer is created.
Trying to fit these diverse needs into the same CRUDService abstraction quickly becomes a nightmare. We introduce optional parameters, callbacks, and a host of if/else statements that make the code bloated and hard to reason about. The abstraction, once clean and simple, now feels like a straitjacket.
The Path to Granular Design
The key insight is recognizing that not all features are created equal. Some patterns are truly universal, while others are domain-specific. The solution isn’t to discard abstraction entirely but to be more selective about what we generalize.
1. Identify Core Patterns
Begin by identifying the truly common patterns that are likely to be shared across many services. In most CRUD operations, getById, getAll, and basic create, update, delete might be universally applicable. These can form the foundation of a core abstraction.
2. Allow for Extension
For more specialized features, use inheritance and composition to allow services to extend the core without forcing every service to adopt the extra functionality. TypeScript’s abstract class is perfect for this:
abstract class BaseCRUDService<T> {
abstract getAll(): T[];
abstract getById(id: string): T | null;
abstract create(data: Partial<T>): T;
abstract update(id: string, updates: Partial<T>): T;
abstract delete(id: string): void;
}
class ProductService extends BaseCRUDService<Product> {
// Core CRUD implementation
}
class OrderService extends BaseCRUDService<Order> {
async validateOrder(order: Order): Promise<boolean> {
// Order-specific validation logic
return true;
}
}
3. Leverage Mixins for Optional Features
For features that are optional but might be useful in multiple services, consider using mixins or utility functions. This keeps the core abstraction lean while allowing services to pick and choose what they need.
function withValidation<T extends BaseCRUDService<any>>(service: T): T & {
validate(data: T['T']): boolean;
} {
return {
...service,
validate(data: T['T']) {
// Validation logic
return true;
},
} as any;
}
class AdvancedProductService extends withValidation(ProductService) {
// Inheriting both CRUD and validation
}
4. Keep Abstractions Focused
The cardinal rule of abstraction is YAGNI (You Ain’t Gonna Need It). If a feature isn’t clearly needed by multiple services, don’t add it to the abstraction. Overgeneralization is the fastest way to create a rigid structure that becomes a maintenance burden.
Real-World Example: The E-commerce Platform
Let’s revisit our e-commerce platform. Initially, we had separate services with repeated CRUD logic. By refactoring, we achieved a cleaner structure:
// Core abstraction
abstract class BaseCRUDService<T> {
// ... standard CRUD methods
}
// Domain-specific services
class ProductService extends BaseCRUDService<Product> {
// Product-specific logic
}
class OrderService extends BaseCRUDService<Order> {
// Order-specific logic
validateOrder(order: Order): boolean {
// Real-time validation
return true;
}
}
// Optional feature using a mixin
function withEmailNotifications<T extends BaseCRUDService<any>>(service: T): T & {
sendCreationEmail(data: T['T']): void;
} {
return {
...service,
sendCreationEmail(data: T['T']) {
// Email sending logic
},
} as any;
}
class NotifiedCustomerService extends withEmailNotifications(CustomerService) {
// Inheriting CRUD and email notifications
}
This structure keeps the core CRUDService focused while allowing domain-specific services to extend it as needed. The withEmailNotifications mixin provides an optional feature that can be applied to any service requiring it.
The Cost of Granularity
Of course, this approach isn’t without its trade-offs. More granular design can lead to increased boilerplate, as each service needs to define its own implementation of the core patterns. There’s also the potential for inconsistency if not managed carefully. But these costs are often outweighed by the benefits of flexibility and maintainability.
Takeaway
The "one design fits all" approach is a seductive starting point, but it’s rarely the final destination. The most successful software systems strike a balance between generalization and specialization. By identifying core patterns, allowing for extension, and being mindful of YAGNI, we can create abstractions that are both reusable and adaptable. The key is not to oversimplify but to design with nuance, recognizing that not all features are created equal—and that’s okay.