.NETSystem Design
June 22, 2026

Refactoring a Rigid .NET Booking Workflow Without Growing Conditional Logic and Service Coupling

A booking workflow often starts as one straightforward method: construct an offer, select a payment provider, load its media, charge the customer, and send a notification. The first version is easy to follow because every decision is in one place.

The design becomes fragile when the business adds new markets, package types, payment providers, and notification consumers. The method accumulates conditional branches, object construction rules leak into application logic, expensive resources are loaded even when nobody needs them, and tests must deal with concrete infrastructure classes.

The goal is not to decorate the code with as many design-pattern names as possible. The goal is to separate the reasons the workflow changes. Each pattern should solve one visible problem and should be removable when that problem does not exist.

The Starting Point: One Method Owns Every Decision

Consider a service that confirms a travel booking. It constructs the offer, chooses a regional gateway, loads a brochure, performs the charge, and sends a message:

public sealed class BookingService
{
    public async Task ConfirmAsync(BookingRequest request)
    {
        var offer = new TravelOffer(
            request.PackageName,
            request.BedCount,
            request.HasBalcony,
            request.IncludesWiFi);

        IPaymentGateway gateway;

        if (request.Market == Market.Nordic)
        {
            gateway = new NordicPaymentGateway();
        }
        else if (request.Market == Market.Mediterranean)
        {
            gateway = new MediterraneanPaymentGateway();
        }
        else
        {
            throw new InvalidOperationException("Unsupported market.");
        }

        var brochure = new BrochureRepository().Load(request.PackageId);

        await gateway.ChargeAsync(request.CustomerId, request.Total);
        await new EmailNotifier().SendConfirmationAsync(request.CustomerId);

        Console.WriteLine($"Loaded brochure bytes: {brochure.Content.Length}");
    }
}

Several responsibilities are mixed together:

  • Offer construction changes when package configuration changes.
  • Gateway selection changes when a market or provider changes.
  • Brochure loading changes when storage changes.
  • Booking execution changes when business steps change.
  • Notification behavior changes when a new consumer is added.
  • Object creation changes when dependencies need different lifetimes.

The class violates the Single Responsibility Principle because it has many unrelated reasons to change. It also works against dependency inversion because high-level booking logic directly creates infrastructure objects.

A more maintainable flow looks like this:

Booking request
      |
      v
Command handler
      |
      +--> Builder creates the configured offer
      |
      +--> Factory selects a payment implementation
      |
      +--> Proxy delays brochure content loading
      |
      +--> Event publisher announces confirmation
                       |
                       +--> Email subscriber
                       +--> Other independent subscribers

Dependency injection composes the complete object graph.

Step 1: Move Complex Construction into a Builder

A constructor is appropriate when an object has a small number of clear, required values. It becomes harder to use when configuration contains many optional steps or when several valid representations must be created consistently.

A fluent Builder keeps that construction logic outside the booking handler:

public sealed class TravelOfferBuilder
{
    private string? _name;
    private int _bedCount;
    private bool _hasBalcony;
    private bool _includesWiFi;

    public TravelOfferBuilder Named(string name)
    {
        _name = name;
        return this;
    }

    public TravelOfferBuilder WithBeds(int count)
    {
        _bedCount = count;
        return this;
    }

    public TravelOfferBuilder WithBalcony()
    {
        _hasBalcony = true;
        return this;
    }

    public TravelOfferBuilder WithWiFi()
    {
        _includesWiFi = true;
        return this;
    }

    public TravelOffer Build()
    {
        if (string.IsNullOrWhiteSpace(_name))
        {
            throw new InvalidOperationException("An offer name is required.");
        }

        if (_bedCount < 1)
        {
            throw new InvalidOperationException("At least one bed is required.");
        }

        return new TravelOffer(
            _name,
            _bedCount,
            _hasBalcony,
            _includesWiFi);
    }
}

The caller now expresses intent instead of managing constructor details:

var familyOffer = new TravelOfferBuilder()
    .Named("Family Escape")
    .WithBeds(3)
    .WithBalcony()
    .WithWiFi()
    .Build();

Do not introduce a builder for every object. A simple record, constructor, or object initializer is clearer when construction is already obvious. The pattern becomes valuable when creation has multiple steps, optional features, validation, or repeatable variants.

Step 2: Replace Regional Conditionals with a Factory

The booking handler should know that it needs a payment gateway. It should not know which concrete class serves each market.

Start with a narrow abstraction:

public interface IPaymentGateway
{
    Market SupportedMarket { get; }

    Task ChargeAsync(
        string customerId,
        decimal total,
        CancellationToken cancellationToken);
}

Each implementation follows the same contract. This is where the Liskov Substitution Principle matters: consumers should be able to replace one gateway with another without discovering that one implementation rejects an operation promised by the interface.

A factory can select the implementation at runtime:

public sealed class PaymentGatewayFactory
{
    private readonly IReadOnlyCollection<IPaymentGateway> _gateways;

    public PaymentGatewayFactory(IEnumerable<IPaymentGateway> gateways)
    {
        _gateways = gateways.ToArray();
    }

    public IPaymentGateway Create(Market market)
    {
        return _gateways.SingleOrDefault(g => g.SupportedMarket == market)
            ?? throw new InvalidOperationException(
                $"No payment gateway is registered for {market}.");
    }
}

The market decision is centralized, and the command handler depends only on the factory and the gateway abstraction. Adding another implementation no longer requires another conditional branch inside the booking workflow.

The interfaces should remain focused. A large gateway interface containing charging, refunds, customer management, reporting, and unrelated administration would violate interface segregation. Consumers should depend only on operations they actually need.

Step 3: Use a Proxy to Delay Expensive Content Loading

A booking confirmation does not always need the full brochure image. Loading it during every request wastes work when the response only needs the file name and tags.

A Proxy can expose the same contract while delaying the expensive content retrieval until the Content property is first requested:

public interface IBrochure
{
    Guid Id { get; }
    string FileName { get; }
    IReadOnlyCollection<string> Tags { get; }
    byte[] Content { get; }
}

public sealed class LazyBrochureProxy : IBrochure
{
    private readonly Lazy<byte[]> _content;

    public LazyBrochureProxy(
        Guid id,
        string fileName,
        IReadOnlyCollection<string> tags,
        Func<byte[]> contentLoader)
    {
        Id = id;
        FileName = fileName;
        Tags = tags;
        _content = new Lazy<byte[]>(contentLoader);
    }

    public Guid Id { get; }
    public string FileName { get; }
    public IReadOnlyCollection<string> Tags { get; }
    public byte[] Content => _content.Value;
}

Metadata remains cheap to access. The content loader runs only when Content is requested, and Lazy<T> keeps the resulting value for later access.

This design has a tradeoff: a property that looks inexpensive may perform costly work on its first access. Keep that behavior visible in naming, documentation, and tests. Do not use lazy loading when the caller must know exactly when I/O happens.

Step 4: Encapsulate the Business Action as a Command

The Command pattern turns a request into an object. This separates the code that asks for an operation from the code that performs it. Commands are useful when operations may need independent handlers, logging, queuing, retries, or undo behavior.

public sealed record ConfirmBookingCommand(
    string CustomerId,
    Market Market,
    decimal Total,
    TravelOffer Offer);

public interface ICommandHandler<in TCommand>
{
    Task HandleAsync(
        TCommand command,
        CancellationToken cancellationToken);
}

The handler coordinates abstractions instead of constructing infrastructure:

public sealed class ConfirmBookingHandler
    : ICommandHandler<ConfirmBookingCommand>
{
    private readonly PaymentGatewayFactory _gatewayFactory;
    private readonly IBookingEventPublisher _eventPublisher;

    public ConfirmBookingHandler(
        PaymentGatewayFactory gatewayFactory,
        IBookingEventPublisher eventPublisher)
    {
        _gatewayFactory = gatewayFactory;
        _eventPublisher = eventPublisher;
    }

    public async Task HandleAsync(
        ConfirmBookingCommand command,
        CancellationToken cancellationToken)
    {
        var gateway = _gatewayFactory.Create(command.Market);

        await gateway.ChargeAsync(
            command.CustomerId,
            command.Total,
            cancellationToken);

        await _eventPublisher.PublishAsync(
            new BookingConfirmed(
                command.CustomerId,
                command.Offer.Name),
            cancellationToken);
    }
}

A separate command class is unnecessary for a trivial method that will never need independent behavior. Use it when the operation has enough lifecycle or coordination concerns to justify an explicit object and handler.

Step 5: Publish an Event Instead of Calling Every Consumer

The handler should not directly call email, analytics, search indexing, and every future consumer. That creates a growing list of dependencies and forces the booking operation to know who reacts to a confirmation.

Publisher/Subscriber communication changes the relationship. The handler publishes a BookingConfirmed event. A broker or event infrastructure delivers it to independent subscribers.

public sealed record BookingConfirmed(
    string CustomerId,
    string OfferName);

public interface IBookingEventPublisher
{
    Task PublishAsync(
        BookingConfirmed message,
        CancellationToken cancellationToken);
}

The important architectural change is not the small interface. It is the removal of direct knowledge between the publisher and its subscribers.

Use Publisher/Subscriber when asynchronous communication, loose coupling, scalability, or multiple independent consumers justify the operational complexity. Prefer a direct method call when one immediate consumer must complete the same simple operation. A message broker introduces delivery, failure handling, retries, and operational concerns that should not be added without a real need.

Step 6: Let Dependency Injection Compose the Application

Dependency injection completes the refactoring. Classes declare what they need through constructors, and the container creates and disposes the object graph.

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddTransient<IPaymentGateway, NordicPaymentGateway>();
builder.Services.AddTransient<IPaymentGateway, MediterraneanPaymentGateway>();
builder.Services.AddTransient<PaymentGatewayFactory>();

builder.Services.AddScoped<
    ICommandHandler<ConfirmBookingCommand>,
    ConfirmBookingHandler>();

builder.Services.AddSingleton<
    IBookingEventPublisher,
    BrokerBookingEventPublisher>();

using IHost host = builder.Build();
host.Run();

Choose lifetimes according to state and usage:

Lifetime Use it when Main risk
Transient A lightweight, stateless object can be created for each request Unnecessary allocations if construction is expensive
Scoped Components must share context within one request or unit of work Using the scope incorrectly outside its intended boundary
Singleton One thread-safe instance should serve the application Shared mutable state and concurrency defects

A classic Singleton implementation is less necessary when the dependency injection container can manage an application-wide instance. Never register a stateful component as a singleton merely because creating one instance looks convenient. Shared services must be safe when multiple requests use them concurrently.

How SOLID Keeps the Patterns Honest

Patterns describe reusable structures. SOLID principles help evaluate whether those structures improve the design.

  • Single Responsibility: construction, provider selection, execution, loading, and notification belong to separate components.
  • Open-Closed: new gateways and subscribers extend behavior without rewriting the booking handler.
  • Liskov Substitution: every gateway must honor the same charging contract.
  • Interface Segregation: handlers depend on small contracts instead of broad service interfaces.
  • Dependency Inversion: booking logic depends on abstractions, while dependency injection supplies concrete implementations.

A pattern that produces large interfaces, surprising substitutions, or more reasons for a class to change has not improved the architecture simply because it has a recognized name.

Common Mistakes

Applying every pattern at once

Refactor from a concrete pain point. Introduce the factory because provider selection changes. Introduce the proxy because resource loading is expensive. Do not add abstractions that have no current responsibility.

Turning dependency injection into a service locator

Constructor injection makes dependencies visible. Pulling arbitrary services from the container inside business code hides those dependencies and makes the object harder to reason about.

Using Publisher/Subscriber for a simple synchronous call

A broker is valuable when independence and asynchronous delivery matter. It is unnecessary when one component must immediately call another and both belong to the same small operation.

Treating a singleton as a global variable

A singleton can centralize shared infrastructure, but shared mutable state introduces concurrency risk. Prefer immutable or thread-safe application-wide services.

Hiding expensive work behind an innocent property

A lazy proxy moves cost from object creation to first access. The cost still exists. Make the loading boundary clear and verify that callers do not trigger it accidentally.

Refactoring Checklist

  • Identify each independent reason the original service changes.
  • Use a builder only when object construction is genuinely complex.
  • Keep runtime implementation selection inside a factory.
  • Verify that every implementation honors its interface contract.
  • Use a proxy only when delayed creation or retrieval removes real unnecessary work.
  • Introduce commands when operations need independent handling or lifecycle behavior.
  • Choose Publisher/Subscriber only when decoupled asynchronous consumers justify it.
  • Prefer constructor injection for mandatory dependencies.
  • Match transient, scoped, and singleton lifetimes to state and concurrency requirements.
  • Confirm that each added abstraction makes the workflow easier to change or test.

Conclusion

The original booking service was difficult to evolve because construction, selection, execution, loading, notification, and dependency creation were all combined. The refactored design gives each decision a clear home.

Builder controls complex object creation. Factory selects runtime implementations. Proxy delays expensive loading. Command represents the business action. Publisher/Subscriber disconnects the action from independent consumers. Dependency injection composes the parts and manages their lifetimes.

The useful skill is not memorizing pattern names. It is recognizing the pressure in the code, choosing the smallest pattern that addresses it, and rejecting patterns whose complexity is greater than the problem they solve.

Share:

Comments0

Home Profile Menu Sidebar
Top