Mert Özen Further With Every Line
Software Design Design Patterns — #24

Anti-Patterns: God Object, Service Locator, Singleton Abuse, Anemic Domain Model and Patternitis

Mert Özen Nov 7, 2026 14 min 6 views
Anti-Patterns: God Object, Service Locator, Singleton Abuse, Anemic Domain Model and Patternitis

An anti-pattern is a solution that looks reasonable at first but produces the same trouble again and again, and recognizing one means knowing the refactoring that moves it to a better structure. The dependency Service Locator hides from ValidateOnBuild, a God Object measured with CA1506, singleton tests that break depending on order, an anemic model whose rule gets bypassed, Golden Hammer, Lava Flow, Poltergeist and patternitis.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore has two services. CheckoutService takes its dependencies through the constructor. The older OrderService takes an IServiceProvider and asks for what it needs itself. Each of the two has one dependency someone forgot to register. The container is built with ValidateOnBuild, an option that checks at build time that every registered service's dependencies can be resolved:

using Microsoft.Extensions.DependencyInjection;

ServiceCollection services = new();
services.AddScoped<IPaymentGateway, FakePaymentGateway>();
services.AddScoped<CheckoutService>();
services.AddScoped<OrderService>();
// IInvoiceWriter and IOrderNotifier were never registered

ServiceProviderOptions options = new() { ValidateOnBuild = true, ValidateScopes = true };
try
{
    using ServiceProvider provider = services.BuildServiceProvider(options);
}
catch (AggregateException ex)
{
    foreach (Exception inner in ex.InnerExceptions)
    {
        Console.WriteLine(inner.Message);
    }
}

services.AddScoped<IInvoiceWriter, ConsoleInvoiceWriter>();
using (ServiceProvider provider = services.BuildServiceProvider(options))
{
    Console.WriteLine("Container built");
    using IServiceScope scope = provider.CreateScope();
    OrderService orders = scope.ServiceProvider.GetRequiredService<OrderService>();
    orders.Place(1042);
}

public interface IPaymentGateway { void Charge(int orderId); }
public interface IInvoiceWriter { void Write(int orderId); }
public interface IOrderNotifier { void OrderPlaced(int orderId); }

public sealed class FakePaymentGateway : IPaymentGateway
{
    public void Charge(int orderId) => Console.WriteLine($"Charged order {orderId}");
}

public sealed class ConsoleInvoiceWriter : IInvoiceWriter
{
    public void Write(int orderId) => Console.WriteLine($"Invoice for order {orderId}");
}

// Dependencies in the constructor: the container sees them at build time
public sealed class CheckoutService(IPaymentGateway payments, IInvoiceWriter invoices)
{
    public void Complete(int orderId)
    {
        payments.Charge(orderId);
        invoices.Write(orderId);
    }
}

// Service Locator: the dependencies are hidden inside the method
public sealed class OrderService(IServiceProvider services)
{
    public void Place(int orderId)
    {
        services.GetRequiredService<IPaymentGateway>().Charge(orderId);
        services.GetRequiredService<IOrderNotifier>().OrderPlaced(orderId);
    }
}

How many errors does the first build report, two or one? After we add the missing registration and build a second time, how far does the program get? Is the order charged?

What Is an Anti-Pattern?

The first four lines of output:

Error while validating the service descriptor 'ServiceType: CheckoutService Lifetime: Scoped ImplementationType: CheckoutService': Unable to resolve service for type 'IInvoiceWriter' while attempting to activate 'CheckoutService'.
Container built
Charged order 1042
Unhandled exception. System.InvalidOperationException: No service for type 'IOrderNotifier' has been registered.

The first build only complained about CheckoutService. OrderService's missing IOrderNotifier showed up nowhere, because the only dependency the container can see is IServiceProvider, which can always be resolved. The second build passed cleanly, the application started, the first order arrived and Place charged the payment and then blew up when it couldn't find the notification service. The customer's card was charged and no confirmation went out. The bug surfaced after deployment, on a real customer.

This post is about solutions like that: ones that look reasonable at first sight but produce the same trouble again and again. Andrew Koenig used the term anti-pattern in his 1995 article "Patterns and Antipatterns" in JOOP (the Journal of Object-Oriented Programming); the AntiPattern note on Martin Fowler's bliki sums up the idea as something that seems like a good idea at first but later leads you into trouble. What made the term widespread was the 1998 book "AntiPatterns: Refactoring Software, Architectures, and Projects in Crisis" (William J. Brown, Raphael C. Malveau, Hays W. McCormick and Thomas J. Mowbray). In the book's template every anti-pattern has two parts: the recurring bad solution and the refactoring that moves it to a better structure. Recognizing an anti-pattern isn't just saying "this is bad"; it's also knowing how to transform it. That's why each section below goes through symptom, cause and transformation.

Service Locator: Hiding the Dependency

Symptom: a class takes IServiceProvider (or a home-made ServiceLocator.Current) in its constructor and calls GetRequiredService inside its methods. The main argument of Mark Seemann's 2010 post "Service Locator is an Anti-Pattern" is what we saw in Guess First: the class's real dependencies can't be read from its API (Application Programming Interface). Someone using OrderService learns that it needs an IOrderNotifier only by reading its source or by blowing up in production. The container's build-time validation, a new OrderService(...) call in a test and the IDE's (Integrated Development Environment) "find usages" can't see that dependency.

Why it appears: usually when a dependency chain turns circular, or when adding "one more parameter" to a constructor looks like a hassle. Sometimes a team using a container for the first time treats it as a global dictionary.

Transformation: turn the GetRequiredService calls into constructor parameters. If the constructor's parameter count grows uncomfortably large, the problem is what the locator was hiding: the class does too much, which is the next section's topic. The SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post "Dependency Inversion Principle: DIP vs DI vs IoC and the Composition Root" (DIP: Dependency Inversion Principle, DI: Dependency Injection, IoC: Inversion of Control) walks through this transformation together with the composition root idea.

Not every injection of IServiceProvider is an anti-pattern. The composition root itself, a small class where a factory selects by a runtime key (like ShippingRateSelector in the series' Strategy post), a BackgroundService opening a scope per unit of work and the parameters of an ASP.NET Core middleware's InvokeAsync are deliberate, limited access to the container. The test is this: is the code using the locator part of the infrastructure, or in the middle of a business rule?

God Object

Symptom: a class thousands of lines long, named Manager, Helper, Processor or after the project itself, that everybody opens for everything. The AntiPatterns book calls it The Blob. In Bookstore it starts like this:

namespace Bookstore;

// Orders, invoices, payments, shipping, stock, notifications, reviews and audit: all in one class
public sealed class BookstoreManager
{
    public Order PlaceOrder(Customer customer, Book book) => new();
    public Invoice IssueInvoice(Order order) => new();
    public Payment Charge(Invoice invoice, Coupon? coupon) => new();
    public Shipment Ship(Order order, Warehouse warehouse) => new();
    public StockItem Reserve(Book book, Warehouse warehouse) => new();
    public EmailMessage ConfirmByEmail(Customer customer, Order order) => new();
    public SmsMessage ConfirmBySms(Customer customer, Shipment shipment) => new();
    public Review ModerateReview(Review review, Author author) => review;
    public AuditEntry Audit(OrderLine line) => new();
}

To see the symptom as a number rather than a feeling, you can use CA1506 (Avoid excessive class coupling). The rule counts how many distinct types a type references; its default threshold for types is 95 and it's off by default in .NET 10. Here we turned it on with .editorconfig and set the threshold to CA1506(Type): 10 in a CodeMetricsConfig.txt file:

Model.cs(4,21): warning CA1506: 'BookstoreManager' is coupled with '15' different types from '1' different namespaces. Rewrite or refactor the code to decrease its class coupling below '11'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1506)

Fifteen types, nine methods. In real projects that number easily passes 95. Why it appears: when a new feature arrives it's added to the nearest big class because "everything about orders is already here"; each addition looks reasonable on its own. The cost is that every change to the class puts unrelated features at risk and a test has to set up fakes for fifteen dependencies.

Transformation: group the methods by whom they change for. Orders and stock (the operations team), invoices and payments (finance), email and SMS (Short Message Service) notifications (marketing) and review moderation (the content team) are four separate actors; the SOLID series post "Single Responsibility Principle: Not "One Thing" but One Actor" covers that split. Moving one group to a new class with each change, rather than all at once, is safer; Large Class and Extract Class in the Clean Code series post "A Code Smell Catalog: Recognizing Smells in C# Code" are the mechanics.

Singleton Abuse

Symptom: a static instance holding mutable state, and code reading it from inside methods. No constructor or method signature shows the dependency:

Console.WriteLine("Order A then B:");
Test.CampaignAppliesDiscount();
Test.NoCampaignMeansFullPrice();

DiscountRegistry.Reset();
Console.WriteLine("Order B then A:");
Test.NoCampaignMeansFullPrice();
Test.CampaignAppliesDiscount();

public static class Test
{
    public static void CampaignAppliesDiscount()
    {
        DiscountRegistry.Instance.Set("BLACKFRIDAY", 0.30m);
        decimal price = new PriceCalculator().PriceOf(100m);
        Console.WriteLine($"  CampaignAppliesDiscount: {(price == 70m ? "passed" : $"FAILED ({price})")}");
    }

    public static void NoCampaignMeansFullPrice()
    {
        decimal price = new PriceCalculator().PriceOf(100m);
        Console.WriteLine($"  NoCampaignMeansFullPrice: {(price == 100m ? "passed" : $"FAILED ({price})")}");
    }
}

// Global and mutable: written from anywhere, visible in no signature
public sealed class DiscountRegistry
{
    public static DiscountRegistry Instance { get; private set; } = new();
    private readonly Dictionary<string, decimal> rates = [];

    public void Set(string campaign, decimal rate) => rates[campaign] = rate;
    public decimal TotalRate => rates.Values.Sum();

    public static void Reset() => Instance = new();
}

public sealed class PriceCalculator
{
    public decimal PriceOf(decimal listPrice) => listPrice * (1 - DiscountRegistry.Instance.TotalRate);
}
Order A then B:
  CampaignAppliesDiscount: passed
  NoCampaignMeansFullPrice: FAILED (70.00)
Order B then A:
  NoCampaignMeansFullPrice: passed
  CampaignAppliesDiscount: passed

The same two tests pass or fail depending on order. The first test wrote the campaign rate into the global registry and never undid it; the second test inherited that rate. When test runners execute tests in parallel and in no fixed order, this means a CI (Continuous Integration) pipeline that is "sometimes red". The production version of the same sharing is bad too: a value written by one request changes another request's calculation, and Dictionary isn't safe for concurrent writes.

Why it appears: the Singleton pattern's "single instance" guarantee and its "access from anywhere" convenience come in the same package. The first is often a real need; the second is the problem itself. The transformation keeps the first and drops the second: take the instance through the constructor and leave the single-instance guarantee to the DI container's singleton lifetime.

Console.WriteLine("Order A then B:");
Test.CampaignAppliesDiscount();
Test.NoCampaignMeansFullPrice();

Console.WriteLine("Order B then A:");
Test.NoCampaignMeansFullPrice();
Test.CampaignAppliesDiscount();

public static class Test
{
    public static void CampaignAppliesDiscount()
    {
        DiscountRates rates = new();
        rates.Set("BLACKFRIDAY", 0.30m);
        decimal price = new PriceCalculator(rates).PriceOf(100m);
        Console.WriteLine($"  CampaignAppliesDiscount: {(price == 70m ? "passed" : $"FAILED ({price})")}");
    }

    public static void NoCampaignMeansFullPrice()
    {
        decimal price = new PriceCalculator(new DiscountRates()).PriceOf(100m);
        Console.WriteLine($"  NoCampaignMeansFullPrice: {(price == 100m ? "passed" : $"FAILED ({price})")}");
    }
}

// A single instance in the app: services.AddSingleton<DiscountRates>()
public sealed class DiscountRates
{
    private readonly Dictionary<string, decimal> rates = [];

    public void Set(string campaign, decimal rate) => rates[campaign] = rate;
    public decimal TotalRate => rates.Values.Sum();
}

public sealed class PriceCalculator(DiscountRates rates)
{
    public decimal PriceOf(decimal listPrice) => listPrice * (1 - rates.TotalRate);
}
Order A then B:
  CampaignAppliesDiscount: passed
  NoCampaignMeansFullPrice: passed
Order B then A:
  NoCampaignMeansFullPrice: passed
  CampaignAppliesDiscount: passed

Now each test builds its own DiscountRates and order doesn't matter. The application still has a single instance; but that's a registration line's decision, not the class's. The series post "Singleton Pattern: Thread-Safe Lazy<T>, Static Fields and Why the DI Singleton Lifetime Is Better" covers that move and the thread-safety side in detail.

Anemic Domain Model

Symptom: domain classes consist only of public get/set properties, and every rule lives in service classes. In his 2003 "AnemicDomainModel" bliki note, Martin Fowler finds this contrary to the basic idea of object-oriented design, putting data and the behavior that works on it together, and calls it an anti-pattern. The practical side of the problem:

AnemicOrder anemic = new() { Status = "Shipped" };
AnemicOrderService service = new();
try
{
    service.AddItem(anemic, "9780441013593", 1);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Service: {ex.Message}");
}
// The nightly import job doesn't know the service and adds to the list directly
anemic.Items.Add(new OrderLine("9780141439518", 2));
Console.WriteLine($"Anemic shipped order now has {anemic.Items.Count} line(s)");

Order order = new(1042);
order.AddItem("9780441013593", 1);
order.Ship();
try
{
    order.AddItem("9780141439518", 2);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Order: {ex.Message}");
}
Console.WriteLine($"Rich shipped order has {order.Items.Count} line(s)");

public sealed record OrderLine(string Isbn, int Quantity);

// Anemic: data only, the rules live elsewhere
public sealed class AnemicOrder
{
    public string Status { get; set; } = "Draft";
    public List<OrderLine> Items { get; set; } = [];
}

public sealed class AnemicOrderService
{
    public void AddItem(AnemicOrder order, string isbn, int quantity)
    {
        if (order.Status == "Shipped")
        {
            throw new InvalidOperationException("Cannot add items to a shipped order.");
        }
        order.Items.Add(new OrderLine(isbn, quantity));
    }
}

// Rich model: the rule sits with the data, the list is read-only outside
public sealed class Order(int id)
{
    private readonly List<OrderLine> items = [];

    public int Id => id;
    public bool IsShipped { get; private set; }
    public IReadOnlyList<OrderLine> Items => items;

    public void AddItem(string isbn, int quantity)
    {
        if (IsShipped)
        {
            throw new InvalidOperationException($"Order {Id} is shipped; items cannot be added.");
        }
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        items.Add(new OrderLine(isbn, quantity));
    }

    public void Ship()
    {
        if (items.Count == 0)
        {
            throw new InvalidOperationException($"Order {Id} has no items.");
        }
        IsShipped = true;
    }
}
Service: Cannot add items to a shipped order.
Anemic shipped order now has 1 line(s)
Order: Order 1042 is shipped; items cannot be added.
Rich shipped order has 1 line(s)

The rule "no items can be added to a shipped order" is written correctly in AnemicOrderService and stopped the call that came through the service. But the rule isn't with the owner of the data; since Items is a public List, the import job that doesn't know the service added to the list without ever seeing the rule. In the rich model the same rule sits inside Order.AddItem and Items is exposed as IReadOnlyList; there is no compilable way around the rule.

Fowler's critique is strong, but it doesn't apply with the same weight to every application. In a CRUD-heavy (Create, Read, Update, Delete) module with almost no business rules, like a product catalog's admin screen that reads and writes data, a class of properties with a thin service is perfectly reasonable; if there's no invariant to protect, there's no gain in moving behavior into the model. An anemic model becomes a real problem once rules start to appear and the same rule starts getting copied into several services. The Clean Code series post "Objects or Data Structures? Records, DTOs and Primitive Obsession" (DTO: Data Transfer Object) covers both ends of the distinction; types deliberately designed without behavior, like a DTO, aren't an anemic model but data carriers crossing a boundary.

Golden Hammer, Lava Flow and Poltergeist

Three more familiar faces from the AntiPatterns book show up more in team habits than in code:

Anti-patternSymptomCauseTransformation
Golden HammerEvery new problem is solved with the same tool: everything is a MediatR request, all data goes through a message queue, a generic repository for every tableThe team knows one tool well, and using it feels less risky than thinkingWriting down at least two options and their tradeoffs for every new problem
Lava FlowCode nobody knows the purpose of and everyone is afraid to delete: unused classes, commented-out blocks, "temporary" feature flagsThe author left, there are no tests, deleting looks riskyGathering evidence through tests and usage analysis, then deleting; Git history keeps the code anyway
PoltergeistShort-lived classes that just call another class and vanish: OrderProcessStarter.Start() containing a single new OrderProcessor().Run()The "everything gets a class" reflex, splitting responsibilities in the wrong placeRemoving the middleman and moving the call to its real owner (Inline Class)

The examples in the table are deliberately not accusatory. MediatR, a generic repository or a feature flag isn't bad on its own; the anti-pattern is applying the tool everywhere regardless of the problem. At the code level, the most common form of that is patterns themselves.

Patternitis: Five Patterns for a Three-Line Job

Symptom: a team that has just finished a design patterns book starts expressing every decision as a pattern. A real example for the free shipping decision:

Cart cart = new([12.99m, 7.50m, 31.00m]);

// Patternitis: four types for a single if
IFreeShippingPolicyFactory factory = new DefaultFreeShippingPolicyFactory();
IFreeShippingPolicy policy = factory.Create(new FreeShippingPolicyOptionsBuilder().WithThreshold(50m).Build());
Console.WriteLine(policy.IsFree(cart));

// The same decision, plain code
Console.WriteLine(cart.Total >= 50m);

public sealed record Cart(decimal[] Prices)
{
    public decimal Total => Prices.Sum();
}

public interface IFreeShippingPolicy { bool IsFree(Cart cart); }
public interface IFreeShippingPolicyFactory { IFreeShippingPolicy Create(FreeShippingPolicyOptions options); }
public sealed record FreeShippingPolicyOptions(decimal Threshold);

public sealed class FreeShippingPolicyOptionsBuilder
{
    private decimal threshold;
    public FreeShippingPolicyOptionsBuilder WithThreshold(decimal value) { threshold = value; return this; }
    public FreeShippingPolicyOptions Build() => new(threshold);
}

public sealed class ThresholdFreeShippingPolicy(FreeShippingPolicyOptions options) : IFreeShippingPolicy
{
    public bool IsFree(Cart cart) => cart.Total >= options.Threshold;
}

public sealed class DefaultFreeShippingPolicyFactory : IFreeShippingPolicyFactory
{
    public IFreeShippingPolicy Create(FreeShippingPolicyOptions options) => new ThresholdFreeShippingPolicy(options);
}
True
True

Both lines give the same answer. The first uses four types, a builder, a factory and a strategy; the second is a comparison. The flexibility the pattern version brings (different policies, different thresholds) isn't in any requirement today. The cost is real, though: a reader has to walk through five types to answer "when is shipping free?". The "When Not to Use It" sections in every post of this series were written precisely against this disease.

The rule is simple: use a pattern when the code asks for it. That's also the core idea of Joshua Kerievsky's 2004 book "Refactoring to Patterns": rather than putting patterns into the design up front, move toward a structure through refactoring when the code really needs it. If you have three ifs and a fourth is coming, consider Strategy; not for a single if. The SOLID series post "Critiquing the Principles: CUPID, Over-Engineering and Where SOLID Really Pays Off" asks the same question, alongside Dan North's CUPID (Composable, Unix philosophy, Predictable, Idiomatic, Domain-based) proposal, about principles.

Common Pitfalls

Labeling all code you dislike as an anti-pattern. "This is a God Object" is a diagnosis; if it isn't followed by symptoms (which changes make people open this class together?) and a transformation plan, it's just a verdict. In code review, showing the concrete cost is more persuasive than naming: "11 of the 14 pull requests that touched this class in the last three months were unrelated to each other".

Rewriting an anti-pattern in one go. Splitting a God Object into ten classes in a week-long branch produces a change that can't be merged and can't be tested. Pulling out one piece with each new feature is slower but safe.

Turning the transformation into a new anti-pattern. Escaping Service Locator by putting everything into constructors and producing twelve-parameter constructors doesn't solve the problem, it makes it visible. That visibility is a good thing: the next step is splitting the class.

Forgetting context. An anemic model is acceptable in a CRUD module, and a global static constant table (like an unchanging list of country codes) isn't singleton abuse. What makes an anti-pattern is less the structure itself than its mismatch with the problem.

A Small Experiment

★☆☆ Convert OrderService from Guess First to constructor injection and see that the first build now reports two errors. Then register IOrderNotifier: where did the risk of ending up in "charged but no confirmation" move to?

★★☆ Split BookstoreManager into at least three classes by actor and run CA1506 again with the same threshold. Which class stayed closest to the threshold, and what does that tell you about it?

★★★ Go on a Lava Flow hunt in your own project: find public classes that haven't changed in the last year and that no test calls (git log and "find usages" are enough). Write the evidence you gathered for deleting one of them as a pull request description.

In this post we looked at structures to avoid. The next post looks at proven code-level patterns that aren't in the GoF (the Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book) catalog but that you'll meet in everyday C# code at least as often: Null Object and Special Case, Specification, Result and Pipeline.