The perils of over-engineering in code

The perils of over-engineering in code

In the realm of software development, finding the perfect balance between simplicity and complexity is a constant challenge. While leveraging advanced techniques and patterns can be beneficial, over-engineering can lead to code that is convoluted, difficult to maintain, and potentially inefficient. After coming across many codebases in my career I thought to write something where we will explore the common pitfalls of over-engineering through an example of an overly complex "Hello, World!" application.

We will examine the excessive use of generics, dependency injection, interfaces, abstracted classes, CQRS, channels, and the decorator pattern, highlighting the importance of simplicity, readability, and pragmatic decision-making.

"Hello World" in the simplest form

In .NET it is extremely easy to get started fast. Since .NET 5 with the introduction of top-level staements in C# 9, creating a super simple application that spits out "Hello World" is literally a one-liner:

Console.WriteLine("Hello World");

Taking "Hello World!" to the extreme

Let's examine an over-engineered version of the "Hello, World!" application that incorporates various advanced concepts and patterns:

using System.Threading.Channels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;

// Excessive use of generics
interface IGreeter<T>
{
    void Greet(T target);
}

class HelloWorldGreeter : IGreeter<string>
{
    public void Greet(string target)
    {
        Console.WriteLine($"Hello {target}!");
    }
}

// Excessive use of interfaces and abstracted classes
interface IRepository<T>
{
    void Save(T entity);
}

class InMemoryRepository<T> : IRepository<T>
{
    public void Save(T entity)
    {
        Console.WriteLine($"Saving {entity} to the in-memory repository.");
    }
}

// Excessive use of dependency injection
class HelloService<T>
{
    private readonly IGreeter<T> _greeter;
    private readonly IRepository<T> _repository;

    public HelloService(IGreeter<T> greeter, IRepository<T> repository)
    {
        _greeter = greeter;
        _repository = repository;
    }

    public void GreetAndSave(T target)
    {
        _greeter.Greet(target);
        _repository.Save(target);
    }
}

// Excessive use of CQRS
class GreetCommand<T>
{
    public T Target { get; set; }
}

interface ICommandHandler<TCommand>
{
    void Handle(TCommand command);
}

class GreetCommandHandler<T> : ICommandHandler<GreetCommand<T>>
{
    private readonly IGreeter<T> _greeter;

    public GreetCommandHandler(IGreeter<T> greeter)
    {
        _greeter = greeter;
    }

    public void Handle(GreetCommand<T> command)
    {
        _greeter.Greet(command.Target);
    }
}

// Excessive use of channels and queuing
class GreetingQueue<T>
{
    private readonly Channel<GreetCommand<T>> _channel;
    private readonly ICommandHandler<GreetCommand<T>> handler;

    public GreetingQueue(ICommandHandler<GreetCommand<T>> handler)
    {
        _channel = Channel.CreateUnbounded<GreetCommand<T>>();
        this.handler = handler;
    }

    public async void EnqueueAsync(GreetCommand<T> command)
    {
        await _channel.Writer.WriteAsync(command);
    }

    public async void ProcessQueueAsync()
    {
        await foreach (var command in _channel.Reader.ReadAllAsync())
        {
            handler.Handle(command);
        }
    }
}

// Excessive use of the decorator pattern
class LoggingDecorator<T> : IGreeter<T>
{
    private readonly IGreeter<T> _greeter;

    public LoggingDecorator(IGreeter<T> greeter)
    {
        _greeter = greeter;
    }

    public void Greet(T target)
    {
        Console.WriteLine("Logging: Before Greet");
        _greeter.Greet(target);
        Console.WriteLine("Logging: After Greet");
    }
}

class Program
{
    static async Task Main()
    {
        // Excessive configuration setup
        var configuration = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json", optional: true)
            .Build();

        var serviceProvider = new ServiceCollection()
            .AddTransient(typeof(IGreeter<string>), typeof(HelloWorldGreeter))
            .AddTransient(typeof(IRepository<>), typeof(InMemoryRepository<>))
            .AddTransient(typeof(ICommandHandler<GreetCommand<string>>), typeof(GreetCommandHandler<string>))
            .AddSingleton<GreetingQueue<string>>()
            .AddSingleton<IConfiguration>(configuration)
            .BuildServiceProvider();

        // Excessive usage of the decorator pattern
        var greeter = serviceProvider.GetService<IGreeter<string>>();
        var decoratedGreeter = new LoggingDecorator<string>(greeter);

        // Excessive instantiation of classes
        var helloService = new HelloService<string>(decoratedGreeter, serviceProvider.GetService<IRepository<string>>());

        // Excessive command handling with CQRS
        var greetCommand = new GreetCommand<string> { Target = "World" };

        // Excessive queuing and processing with channels
        var greetingQueue = serviceProvider.GetService<GreetingQueue<string>>();
        greetingQueue.EnqueueAsync(greetCommand);
        greetingQueue.ProcessQueueAsync();

        // ... Additional code for the "Hello, World!" application
    }
}

Understanding the over-engineered code

The over-engineered "Hello World!" application demonstrates the excessive use of generics, dependency injection, interfaces, abstracted classes, CQRS, channels, and the decorator pattern. While each concept has its merits, their excessive use in this example introduces unnecessary complexity and reduces code readability and maintainability.

The perils of over-engineering

Reduced readability

The excessive use of generics, interfaces, abstracted classes, and dependency injection increases the cognitive load for developers, making the code harder to understand and maintain. A simple "Hello World!" application does not warrant such complexity.

Over-reliance on advanced concepts

Leveraging advanced concepts like CQRS and channels for a basic application introduces unnecessary overhead and can negatively impact performance and scalability.

Maintenance overhead

Over-engineering increases the effort required to maintain and extend the codebase. Simple changes or bug fixes can become challenging and time-consuming tasks.

Potential performance issues

Excessive abstractions and indirection layers can introduce performance bottlenecks, negatively affecting the responsiveness of the application.

Striving for simplicity and pragmatism

Start simple

Begin with the simplest solution that effectively solves the problem at hand. Unnecessary complexity should be avoided unless there is a clear benefit. Remember KISS!

Evaluate the need for abstractions

Assess the specific requirements of the application before introducing abstractions like generics, interfaces, and abstracted classes. Opt for simplicity unless the benefits of abstraction outweigh the associated complexity.

Consider the trade-offs

Before implementing advanced concepts such as CQRS, channels, or the decorator pattern, carefully evaluate the impact on code readability, maintainability, and performance. Determine if the benefits outweigh the added complexity.

Refactor when necessary

Periodically review the codebase and refactor it when complexity exceeds the needs of the application. Seek a balance between simplicity and extensibility, ensuring that the code remains readable and maintainable.

Conclusion

In the quest for building robust software, striking the right balance between simplicity and complexity is paramount. Over-engineering can introduce unnecessary complexity, reduce code readability, and hinder maintainability. By starting with a simple solution and incorporating advanced concepts only when warranted, developers can ensure pragmatic code that is efficient, maintainable, and scalable.

Remember, simplicity should be the guiding principle, and each added concept or pattern should have a clear purpose. Business does not care that your code is using the latest DI frameowork or the fact that you abstracted the code in such a way that it is "easy to maintain" one day. Always try and follow the guided principles but remember to keep on questioning the code if it is really adding value.

By carefully weighing the benefits and drawbacks, and refactoring when necessary, developers can create code that is not only functional but also elegant and sustainable in the long run.

Cheers!