Applying Aspect-Oriented Programming Through SOLID Design Principles

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.

Tags: aop SOLID CQS Decorator Pattern Command Query Separation

Posted on Mon, 18 May 2026 05:05:29 +0000 by shinichi_nguyen