Where Do You Put Your Business Logic?

Where Do You Put Your Business Logic?

Code Rant Chronicles

Welcome to the eternal question every developer faces: “Where do I actually put my business logic?” We’ve all seen the patterns:

  • Monstrous service classes that drag in half the codebase as dependencies.

  • Anemic domain objects that do basically nothing.

  • Or the glorious opposite: domain models so “rich” they’ve become God Objects, controlling the fate of all creation.

Fun times, right? In this article, we’ll dissect how you can escape the “one service to rule them all” approach. We’ll explore a Functional Programming (FP) style—slicing logic into pure functions and pushing side effects to the edges—and a modern OO style with domain-rich entities that still keep external calls out. Either beats a service that needs 13 mocks to pass a single unit test.

So strap in, my friend, because if you’re sick of writing 200 lines of mocking code just to test a 10-line function, it’s time for a better approach. Or at least a much smaller aneurysm.

The Wrong Way

We’ve all seen it, amigos. You start with an IRepository<T>:

public interface IRepository<T> where T : class
{
    T GetById(int id);
    void Add(T entity);
    void Update(T entity);
    void Delete(T entity);
}

Then you create an OrderService that depends on the repository:

public class OrderService
{
    private readonly IRepository<Order> _orderRepository;

    public OrderService(IRepository<Order> orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public Order GetOrderById(int id)
    {
        return _orderRepository.GetById(id);
    }

    public void AddOrder(Order order)
    {
        // Some business logic: e.g., setting initial Status
        order.Status = OrderStatus.New;
        order.CreatedDate = DateTime.UtcNow;

        _orderRepository.Add(order);
    }

    public void UpdateOrder(Order order)
    {
        // Additional logic: e.g., preventing updates if the Order is shipped
        if (order.Status == OrderStatus.Shipped)
            throw new InvalidOperationException("Can't modify a shipped order.");

        _orderRepository.Update(order);
    }

    public void DeleteOrder(Order order)
    {
        // Additional logic: e.g., logging or verifying something
        if (order.Status == OrderStatus.New)
            throw new InvalidOperationException("You can't delete a NEW order without reason!");

        _orderRepository.Delete(order);
    }
}

And voilà—for a while, it looks clean. But then the requirements grow:

  • You need notification services for shipped orders.

  • You add a payment gateway dependency.

  • You must call inventory service APIs.

  • You have to integrate with CRM or ERP or whatever acronym-of-the-day.

Suddenly:

public class OrderService
{
    public OrderService(
        IRepository<Order> orderRepository, 
        INotificationService notificationService, 
        IPaymentGateway paymentGateway, 
        IInventoryClient inventoryClient,
        IAuditLogger auditLogger, 
        ISomeOtherDependency someOtherThing, 
        ...
    )
    {
        // ...
    }
}

Congratulations, your OrderService is now a monster. And don’t even get me started on unit tests. With xUnit, you end up mocking half your codebase to isolate a single logic path:

public class OrderServiceTests
{
    [Fact]
    public void AddOrder_Should_Set_Status_To_New_And_Call_Repository_Add()
    {
        var repoMock = new Mock<IRepository<Order>>();
        var notificationMock = new Mock<INotificationService>();
        var paymentMock = new Mock<IPaymentGateway>();
        var inventoryMock = new Mock<IInventoryClient>();
        var auditMock = new Mock<IAuditLogger>();
        var otherMock = new Mock<ISomeOtherDependency>();
        // ... more mocks?

        var service = new OrderService(
            repoMock.Object, 
            notificationMock.Object,
            paymentMock.Object,
            inventoryMock.Object,
            auditMock.Object,
            otherMock.Object
            // ...
        );

        var newOrder = new Order { /* ... */ };

        service.AddOrder(newOrder);

        repoMock.Verify(r => r.Add(It.IsAny<Order>()), Times.Once);
        // ...
    }
}

One test is already exhausting, and we’ve only just begun. Sound familiar, amigo? Does this approach truly separate concerns? I doubt it.

If your domain rules and infrastructure logic are glued in the same class you end up with a “god service” that knows everything—validation, domain invariants, external calls, etc. Sure, you have interfaces, but if your OrderService is handling the entire universe, we’ve just replaced one big ball of mud with a lot of tiny, scattered mud pies.

Hence the question: where do you put your business logic?


A Better Way (FP-style)

Functional-Style Refactoring

In the .NET community, there’s a taboo around static methods—especially from folks deep in OOP. But what if we took a page from the Functional Programming (FP) handbook?

Pure Functions & Determinism

  • A pure function always returns the same result for the same inputs and has no side effects.

  • A deterministic function behaves predictably, depends solely on its input, and doesn’t do sneaky things like mutate global state.

FP wisdom says: “Keep your logic as deterministic as possible.” How? By extracting the real business logic into static extension methods (or plain static methods). You pass in the data and behaviors you need—no hidden dependencies, no labyrinth of mocks.

Functional Core, Imperative Shell, and Dependency Rejection

  • Functional Core: Contains pure, deterministic logic—no direct DB calls, no HTTP requests.

  • Imperative Shell: Coordinates the real-world side effects—fetching data, calling services, saving to DB.

And here’s an important concept: Dependency Rejection.

Coined by Mark Seemann, it’s the idea of avoiding dependencies in our core business logic by keeping I/O and other impure code at the edges of our domain.

Instead of injecting objects or interfaces into your core domain logic, you “reject” them. The domain logic remains dependency-free, while all the side effects are handled externally, passed in as functions if needed.

Refactoring the Example

Step 1: Move Logic to Static Extension Methods

public static class OrderLogic
{
    // 1) Create a new Order from basic input.
    //    Return a "Fail" result if data is invalid.
    public static Result<Order> CreateOrder(string customerName, List<LineItem> lineItems)
    {
        if (string.IsNullOrEmpty(customerName))
            return Result<Order>.Fail("Customer name is required.");

        if (lineItems is null || !lineItems.Any(li => li.Quantity > 0))
            return Result<Order>.Fail("At least one positive-quantity item is required.");

        // If valid, build the Order
        var order = new Order
        {
            CustomerName = customerName,
            LineItems = lineItems,
            Status = OrderStatus.None, // set an initial status
            CreatedDate = DateTime.MinValue // will set later
        };

        return order; // Implicitly Result<Order>.Ok(order)
    }

    // 2) Transform the order to "New" status
    public static Order MarkAsNew(Order order)
    {
        order.Status = OrderStatus.New;
        order.CreatedDate = DateTime.UtcNow;
        return order;
    }

    // 3) The main orchestration function.
    //    - Create the order
    //    - Transform it (mark as new)
    //    - Persist to DB
    //    - Notify user
    //    - Charge payment
    public static Result<Order> ProcessNewOrder(
        string customerName,
        List<LineItem> lineItems,
        Action<Order> persist,
        Action<Order> notify,
        Action<Order> charge)
    {
        // Start: Create the Order from input
        return CreateOrder(customerName, lineItems)
            // Then transform it into "New"
            .Map(o => MarkAsNew(o))
            // Then run side effects in sequence:
            .Map(o => { persist(o); return o; })
            .Map(o => { notify(o); return o; })
            .Map(o => { charge(o); return o; });
    }
}

What’s Happening Here?

CreateOrder checks basic invariants (non-empty customer name, at least one valid line item). Returns a Result indicating success/fail. MarkAsNew purely updates the Order. ProcessNewOrder is the “imperative” logic in an FP style:

  • Create → returns Result<Order>.

  • Map (transform) → mark as new.

  • Map (side effects) → persist, notify, charge in sequence. If any step fails in future expansions, you could switch to Bind for more advanced failover logic.

If any step fails before side effects, the chain short-circuits, returning a fail result. (In the above snippet, side effects are unconditional, but you can refine it by using Bind if needed.)

💡
For introduction to Result type and code samples read my article Flow Control with Either Monad in C#

Step 2: Minimal API (The Imperative Shell)

In your Program.cs (or top-level file), you wire it up:

var builder = WebApplication.CreateBuilder(args);

// Register external dependencies
builder.Services.AddSingleton<IRepository<Order>, InMemoryOrderRepository>();
builder.Services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
builder.Services.AddSingleton<INotificationService, EmailNotificationService>();
// etc.

var app = builder.Build();

// A minimal record to represent incoming request data
public record OrderRequest(string CustomerName, List<LineItem> Items);

app.MapPost("/orders", (
    OrderRequest request,
    IRepository<Order> repo,
    IPaymentGateway pay,
    INotificationService notif
) =>
{
    var result = OrderLogic.ProcessNewOrder(
        customerName: request.CustomerName,
        lineItems: request.Items,
        persist: o => repo.Add(o),
        notify: o => notif.NotifyNewOrder(o),
        charge: o => pay.Charge(o)
    );

    if (!result.IsSuccess)
    {
        return Results.BadRequest(result.ErrorMessage);
    }

    var createdOrder = result.Value;
    return Results.Created($"/orders/{createdOrder.Id}", createdOrder);
});

app.Run();

What’s Happening Here?

We capture raw user input (OrderRequest) from the endpoint. Call ProcessNewOrder with the required side-effect delegates:

  • persist → repo.Add(o)

  • notify → notif.NotifyNewOrder(o)

  • charge → pay.Charge(o)

If any step up to the side effects fails (like invalid data in CreateOrder), you get a Fail result. The .Map chain short-circuits, skipping the rest. We return BadRequest if result.IsSuccess is false. Otherwise, we respond with 201 Created.

Testing the Flow

Unit Testing the Creation Logic

public class OrderLogicTests
{
    [Fact]
    public void CreateOrder_Fails_When_NoCustomerName()
    {
        var result = OrderLogic.CreateOrder("", new List<LineItem> { new() { Quantity = 1 } });

        Assert.False(result.IsSuccess);
        Assert.Equal("Customer name is required.", result.ErrorMessage);
    }

    [Fact]
    public void CreateOrder_Ok_When_Valid()
    {
        var result = OrderLogic.CreateOrder("Alice", new List<LineItem> { new() { Quantity = 1 } });

        Assert.True(result.IsSuccess);
        Assert.Equal("Alice", result.Value.CustomerName);
        Assert.Single(result.Value.LineItems);
    }
}

Testing ProcessNewOrder Without Real Side Effects

[Fact]
public void ProcessNewOrder_Success_PersistsAndNotifiesAndCharges()
{
    // We'll pass in dummy data that is valid
    var persistCalled = false;
    var notifyCalled = false;
    var chargeCalled = false;

    var result = OrderLogic.ProcessNewOrder(
        "Bob",
        new List<LineItem> { new() { Quantity = 2 } },
        persist: o => persistCalled = true,
        notify: o => notifyCalled = true,
        charge: o => chargeCalled = true
    );

    Assert.True(result.IsSuccess);
    Assert.NotEqual(default, result.Value.CreatedDate);
    Assert.Equal(OrderStatus.New, result.Value.Status);
    Assert.True(persistCalled, "Persist side effect should happen.");
    Assert.True(notifyCalled, "Notify side effect should happen.");
    Assert.True(chargeCalled, "Charge side effect should happen.");
}

We verify the domain logic and confirm that if data is valid, the side-effect delegates get called—no giant constructor or 20 mocks. If CreateOrder had failed, the .Map chain short-circuits, skipping the rest.


A Better Way (Modern 00)

Domain Rich Model

Yes, some folks love an “anemic domain model” (just data, no behavior), then pile all the logic in a god service. But you can do modern OO with domain-rich objects that:

  • Keep invariants (e.g., “an Order can’t be shipped if it’s not paid”) inside the entity.

  • Avoid letting “services” overshadow the domain.

  • Don’t turn into a 5,000-line monster object with dependencies on everything.

The key: Let your domain object store state + logic but keep external calls out of it.

public class Order
{
    public int Id { get; private set; }
    public string CustomerName { get; private set; }
    public List<LineItem> LineItems { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTime CreatedDate { get; private set; }

    // Private ctor for EF or deserialization
    private Order() { }

    public Order(string customerName, List<LineItem> items)
    {
        if (string.IsNullOrEmpty(customerName))
            throw new ArgumentException("Customer name is required.");
        if (items is null || !items.Any(li => li.Quantity > 0))
            throw new ArgumentException("At least one positive-quantity line item required.");

        CustomerName = customerName;
        LineItems = items;
        Status = OrderStatus.New;
        CreatedDate = DateTime.UtcNow;
    }

    public void MarkAsShipped()
    {
        if (Status != OrderStatus.New && Status != OrderStatus.Paid)
            throw new InvalidOperationException("Cannot ship if not in NEW or PAID status.");

        Status = OrderStatus.Shipped;
    }

    public void UpdateCustomerName(string newName)
    {
        if (string.IsNullOrEmpty(newName))
            throw new ArgumentException("Customer name cannot be empty.");

        CustomerName = newName;
    }

    // etc. for other domain behaviors
}

No external services or repository calls inside this class. But we do have domain invariants and state transitions. This is what modern OO can look like: data + behavior that ensures correctness.

Minimal API Shell (OO Version)

We’d still have an application layer or controller that decides when to call MarkAsShipped(), saves to DB, notifies external services, etc. But the domain object itself is rich—it enforces invariants. No 50 dependencies in the constructor.

var builder = WebApplication.CreateBuilder(args);

// Register external dependencies again
builder.Services.AddSingleton<IRepository<Order>, InMemoryOrderRepository>();
builder.Services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
builder.Services.AddSingleton<INotificationService, EmailNotificationService>();
// etc.

var app = builder.Build();

public record OrderRequestOO(string CustomerName, List<LineItem> Items);

app.MapPost("/orders-oo", (
    OrderRequestOO request,
    IRepository<Order> repo,
    IPaymentGateway pay, // maybe we want to pay?
    INotificationService notif
) =>
{
    try
    {
        // Construct a rich domain object
        var order = new Order(request.CustomerName, request.Items);

        // If needed, call domain methods or transitions, e.g. MarkAsPaid() or so
        // order.MarkAsPaid(); // just an example

        // Persist
        repo.Add(order);

        // Possibly do other side effects
        pay.Charge(order);
        notif.NotifyNewOrder(order);

        return Results.Created($"/orders-oo/{order.Id}", order);
    }
    catch (Exception ex)
    {
        // If domain invariants fail, we catch them here
        return Results.BadRequest(ex.Message);
    }
});

app.Run();

Testing The Modern OO Domain

With a rich domain object, you test invariants directly:

public class OrderTests
{
    [Fact]
    public void Ctor_Throws_When_NoCustomerName()
    {
        Assert.Throws<ArgumentException>(() =>
            new Order("", new List<LineItem> { new() { Quantity = 1 } }));
    }

    [Fact]
    public void Ctor_Sets_New_Status_And_CreatedDate()
    {
        var order = new Order("Alice", new List<LineItem> { new() { Quantity = 1 } });
        Assert.Equal(OrderStatus.New, order.Status);
        Assert.NotEqual(default, order.CreatedDate);
    }

    [Fact]
    public void MarkAsShipped_Throws_If_NotNewOrPaid()
    {
        var order = new Order("Bob", new List<LineItem> { new() { Quantity = 1 } });
        order.MarkAsShipped(); // This is fine, since status is New by default

        // But if status was something else, it would throw
    }
}

No mocks, just domain rules. If you want to test the shell (e.g., does it call repo.Add?), you can do an integration test or a smaller test with a FakeRepository.


FP vs. Modern OO

The FP-Style

  • Create domain data with small, pure-ish functions that return Result<T>.

  • Chain transformations (MarkAsNew) and side effects (persist, notify, charge) using .Map and .Bind.

  • All domain logic is separate from external calls—no big service class.

  • Tests are easy, with minimal mocks.

The Modern OO Style

  • Rich domain objects that enforce invariants in constructors and domain methods.

  • Infrastructure and side effects live in the minimal API or a separate “application service,” not the domain object.

  • Domain logic is directly embedded in the entity, no need for a separate function to “create” an order.

  • Also simpler to test, as you verify invariants with straightforward constructor or method calls.

Both approaches remove the giant “god service” with 10 dependencies. Both avoid an anemic domain model (where you do everything in a service class). Which style you pick depends on your preference and your team’s skill set.

Final Rant

In short:

  • Stop letting a single “service” ingest all your dependencies until it becomes the Stay Puft Marshmallow monster of your codebase.

  • Embrace either an FP approach—pure logic returning Result<T> for success/failure, with side effects at the minimal API layer or shell—or a modern OO approach—domain objects that enforce invariants themselves but keep external calls outside.

  • Enjoy the sudden disappearance of your 20-interface constructor, the meltdown of your mocking fiasco, and the sweet relief of testing actual logic instead of wrangling DI containers.

Does that mean you’ll never again see a monstrous constructor? Well, I can’t promise that. But if you follow the ideas here, you just might keep your codebase from becoming an overengineered carnival of side effects and test nightmares. And that, amigo, is worth the rant.

End of story. Now go forth, banish the “God Service,” and let your domain logic breathe!