Professional kitchens prioritize efficiency through meticulous preparation and optimized layout—concepts mirrored in software architecture. Just as mise en place ensures all tools and ingredients are ready, a well-structured codebase minimizes redundancy and maximizes maintainability. In this context, Aspect-Oriented Programming (AOP) emerges as a key strategy for managing cross-cuting concerns like logging, security, and transactions without scattering boilerplate logic across the system.
AOP doesn't inherently require specialized tools. Instead, it can be driven by sound object-oriented design—particularly the SOLID principles. This approach avoids the pitfalls of tool-dependent AOP (like dynamic interception or compile-time weaving), which often introduce tight coupling, testability issues, or loss of compile-time safety.
Refactoring Toward Maintainable AOP with SOLID
Consider an initial IProductService interface that accumulates numerous methods over time:
public interface IProductService
{
IEnumerable<DiscountedProduct> GetFeaturedProducts();
void DeleteProduct(Guid productId);
Product GetProductById(Guid productId);
void InsertProduct(Product product);
// ... several more methods
}
This design violates multiple SOLID principles:
- Interface Segregation Principle (ISP): Clients depend on methods they don’t use.
- Single Responsibility Principle (SRP): The implementation handles too many unrelated concerns.
- Open/Closed Principle (OCP): Adding features or aspects forces changes across many classes.
To resolve this, we apply a series of design transformations:
Step 1: Separate Queries from Commands
Split the interface into read-only (IProductQueryService) and write-only (IProductCommandService) abstractions, aligning with Command Query Separation (CQS).
Step 2: Decompose into Single-Method Interfaces
Break IProductCommandService into granular interfaces—one per operation:
public interface IAdjustInventoryService
{
void AdjustInventory(Guid productId, bool decrease, int quantity);
}
public interface IUpdateProductReviewTotalsService
{
void UpdateProductReviewTotals(Guid productId, ProductReview[] reviews);
}
Step 3: Introduce Parameter Objects
Encapsulate method arguments in to dedicated command objects and standardize method names to Execute:
public class AdjustInventory
{
public Guid ProductId { get; set; }
public bool Decrease { get; set; }
public int Quantity { get; set; }
}
public interface ICommandService
{
void Execute(object command);
}
Step 4: Apply Generic Abstraction to Enforce LSP
The non-generic ICommandService violates the Liskov Substitution Principle (LSP)—any command could be passed to any handler, causing runtime errors. Fix this with generics:
public interface ICommandService<TCommand>
{
void Execute(TCommand command);
}
public class AdjustInventoryService : ICommandService<AdjustInventory>
{
public void Execute(AdjustInventory command)
{
// No casting needed; type-safe by design
}
}
This generic abstraction enables safe, compile-time verified composition while preserving SRP and OCP.
Implementing Cross-Cutting Concerns as Decorators
With a uniform ICommandService<TCommand> contract, cross-cutting concerns become single, reusable decorators:
Transaction Management
public class TransactionCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
public void Execute(TCommand command)
{
using var scope = new TransactionScope();
this.decoratee.Execute(command);
scope.Complete();
}
}
Auditing
public class AuditingCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
public void Execute(TCommand command)
{
this.decoratee.Execute(command);
this.context.AuditEntries.Add(new AuditEntry
{
Operation = typeof(TCommand).Name,
Data = JsonConvert.SerializeObject(command),
UserId = this.userContext.CurrentUser.Id,
TimeOfExecution = this.timeProvider.Now
});
this.context.SaveChanges();
}
}
Security via Passive Attributes
Use metadata attributes—not behavior—to declare authorization rules:
[PermittedRole(Role.InventoryManager)]
public class AdjustInventory { /* ... */ }
public class SecureCommandServiceDecorator<TCommand>
: ICommandService<TCommand>
{
private static readonly Role PermittedRole =
typeof(TCommand).GetCustomAttribute<PermittedRoleAttribute>()?.Role
?? throw new InvalidOperationException("[PermittedRole] missing.");
public void Execute(TCommand command)
{
if (!this.userContext.IsInRole(PermittedRole))
throw new SecurityException();
this.decoratee.Execute(command);
}
}
Composing the Object Graph
Decorators nest cleanly around concrete handlers:
var service = new SecureCommandServiceDecorator<AdjustInventory>(
userContext,
new TransactionCommandServiceDecorator<AdjustInventory>(
new AuditingCommandServiceDecorator<AdjustInventory>(
userContext, timeProvider, context,
new AdjustInventoryService(repository)
)
)
);
To avoid repetition, extract decoration into a helper method:
private ICommandService<T> Decorate<T>(ICommandService<T> handler, CommerceContext ctx) =>
new SecureCommandServiceDecorator<T>(
userContext,
new TransactionCommandServiceDecorator<T>(
new AuditingCommandServiceDecorator<T>(userContext, timeProvider, ctx, handler)
)
);
This design ensures that adding new commands or aspects requires minimal, localized changes—fulfilling OCP and DRY.
Key Insights
- Cross-cutting concerns should be aplied at the business operation level, not the repository level, to ensure correct transaction boundaries and meaningful audit trails.
- Parameter objects (commands) become explicit artifacts representing use cases, enabling serialization, replay, and monitoring.
- The number of classes is not a measure of complexity; well-scoped, single-responsibility types improve maintainability.
- SOLID-driven AOP provides compile-time safety, testability, and flexibility—unlike tool-based alternatives.
While dynamic interception or IL weaving may offer quick fixes in legacy systems, they compromise long-term evolvability. By contrast, design-first AOP—rooted in SOLID—yields a codebase that is both resilient and adaptable.