Executando verificação de segurança...
7

Três Olhares sobre Arquitetura de Software: Fowler, Evans e Uncle Bob: 1.4 - Service Layer: orquestrando casos de uso

Capa

Nos últimos vinte anos, três nomes se tornaram referência inevitável sempre que falamos sobre arquitetura de software: Martin Fowler, Eric Evans e Robert C. Martin. Cada um deles trouxe uma perspectiva única, mas complementar, para o ofício de projetar sistemas duradouros. Fowler, com seus padrões de aplicação empresarial, nos ofereceu um catálogo de soluções pragmáticas para problemas recorrentes. Evans, com o Domain-Driven Design, nos mostrou como aproximar o software da linguagem do negócio e como dar forma ao que realmente importa em um sistema: o domínio. E Uncle Bob, com a Clean Architecture, reforçou os princípios e as camadas que protegem esse domínio da corrosão do tempo, dos frameworks e da infraestrutura.

Esta série de artigos nasce do desejo de cruzar esses três olhares. A cada padrão descrito por Fowler, vamos buscar o eco que ele encontra em Evans e em Martin. Em alguns momentos, haverá convergência; em outros, choque. Mas em todos, a discussão nos ajuda a entender não apenas o “como” programar, mas o “porquê” de certas escolhas arquiteturais.

Vamos usar exemplos em C#, trazer referências aos livros originais e sempre terminar com uma reflexão sobre quando e por que adotar (ou evitar) cada padrão. A ideia não é canonizar um estilo, mas oferecer um mapa de raciocínio para que você, como engenheiro de software, tenha mais clareza ao tomar suas decisões.


Índice da Série

Parte 1 – A lógica do domínio

Parte 2 – Persistência e Infraestrutura

Parte 3 – Apresentação e Integração

1.4 Service Layer: orquestrando casos de uso

Introdução

Quando no artigo anterior tratamos o Domain Model como o coração da aplicação, faltou falar da artéria que leva esse sangue aonde ele precisa chegar. É aqui que o Service Layer entra em cena. Martin Fowler descreveu esse padrão para dar uma “face” transacional à sua aplicação: em vez de UI, APIs ou jobs conversarem direto com entidades e agregados, elas pedem para um serviço executar um caso de uso com começo, meio e fim. Eric Evans reforça essa ideia ao separar com clareza o que é um Application Service, o coordenador do fluxo, do que é um Domain Service, um pedaço de regra de negócio que não coube naturalmente em uma entidade ou value object. E Robert C. Martin amarra tudo na Clean Architecture chamando essa camada de Use Cases, lembrando que ela deve ser independente de framework, banco e UI. Em outras palavras: o domínio diz o quê; o Service Layer diz o como; a infraestrutura diz o onde e o com o quê.

Service Layer como maestro dos fluxos de negócio

O pulo do gato está em não confundir orquestração com regra de negócio. O Service Layer existe para abrir e finalizar transações, coordenar repositórios, validar pré-condições mais “externas” (autorização, idempotência, consistência de parâmetros), publicar eventos de integração e, principalmente, contar a história de um caso de uso de ponta a ponta de forma testável. A lógica que muda o estado significativo do negócio — cálculo de preço, políticas de desconto, regras de estoque — precisa viver no domínio. Quando essa linha borra, o serviço cresce, fica inchado e vira um Transaction Script com roupa de objeto. Fowler alertou para esse cheiro há mais de vinte anos; Evans mostrou como evitá-lo com um modelo rico e serviços magros; e Uncle Bob acusaria a violação do SRP e a perda de independência da arquitetura. O antídoto é disciplina: Application Services pequenos, com nomes que soem como histórias reais (“RegistrarPedido”, “AprovarCrédito”, “CancelarReserva”), delegando o trabalho pesado às entidades, aos value objects, aos agregados e, quando fizer sentido, a Domain Services focados.

Para ficar concreto, imagine o caso de uso “Registrar um novo pedido”. O serviço de aplicação recebe o comando, garante a existência do cliente, cria o agregado Pedido e o persiste. Note que ele não “calcula” o pedido; ele pede para o agregado fazer isso. É essa separação que mantém o domínio expressivo e o fluxo claro e testável.

using System;
using System.Collections.Generic;
using System.Linq;

// Application Layer (Service Layer / Use Case)
public class RegisterOrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IUnitOfWork _uow;

    public RegisterOrderService(
        IOrderRepository orderRepository,
        ICustomerRepository customerRepository,
        IUnitOfWork uow)
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _uow = uow;
    }

    public OrderId Execute(RegisterOrderCommand command)
    {
        if (command is null) throw new ArgumentNullException(nameof(command));
        if (command.Items is null || command.Items.Count == 0)
            throw new InvalidOperationException("Order must contain at least one item.");

        using var tx = _uow.Begin();

        var customer = _customerRepository.GetById(command.CustomerId);
        if (customer is null)
            throw new InvalidOperationException("Customer not found.");

        var order = Order.Create(customer.Id);

        foreach (var item in command.Items)
        {
            order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
        }

        order.Confirm();

        _orderRepository.Save(order);
        tx.Commit();

        return order.Id;
    }
}

// Command (input DTO)
public record RegisterOrderCommand(
    CustomerId CustomerId,
    List<RegisterOrderItemDto> Items);

public record RegisterOrderItemDto(
    ProductId ProductId,
    Quantity Quantity,
    Money UnitPrice);

// Domain (Aggregate Root)
public class Order
{
    private readonly List<OrderLine> _lines = new();

    public OrderId Id { get; }
    public CustomerId CustomerId { get; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

    private Order(OrderId id, CustomerId customerId)
    {
        Id = id;
        CustomerId = customerId;
        Status = OrderStatus.Draft;
    }

    public static Order Create(CustomerId customerId)
        => new(new OrderId(Guid.NewGuid()), customerId);

    public void AddItem(ProductId productId, Quantity quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify a confirmed order.");

        if (quantity.Value <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
        if (unitPrice.Amount <= 0) throw new ArgumentOutOfRangeException(nameof(unitPrice));

        var existingIndex = _lines.FindIndex(l => l.ProductId == productId);

        if (existingIndex < 0)
        {
            _lines.Add(new OrderLine(productId, quantity, unitPrice));
        }
        else
        {
            var existing = _lines[existingIndex];

            // Basic currency/price consistency check for the same product line.
            if (existing.UnitPrice.Currency != unitPrice.Currency)
                throw new InvalidOperationException("Cannot mix currencies for the same product line.");

            if (existing.UnitPrice.Amount != unitPrice.Amount)
                throw new InvalidOperationException("Cannot change unit price for an existing product line.");

            var increased = existing.Increase(quantity);
            _lines[existingIndex] = increased; // Replace VO instance (immutability preserved).
        }
    }

    public void Confirm()
    {
        if (!_lines.Any())
            throw new InvalidOperationException("Order must have at least one line.");

        Status = OrderStatus.Confirmed;
    }

    public Money Total()
    {
        if (!_lines.Any())
            throw new InvalidOperationException("Cannot compute total for an empty order.");

        var currency = _lines[0].UnitPrice.Currency;

        return _lines
            .Select(l => l.Total)
            .Aggregate(new Money(0m, currency), (acc, next) => acc.Add(next));
    }
}

public enum OrderStatus { Draft, Confirmed }

// Value Objects (VOs)
public readonly record struct OrderId(Guid Value);
public readonly record struct CustomerId(Guid Value);
public readonly record struct ProductId(Guid Value);

public readonly record struct Quantity(int Value)
{
    public Quantity Increase(int amount)
    {
        if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
        checked { return new Quantity(Value + amount); }
    }
}

public readonly record struct Money(decimal Amount, string Currency)
{
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add money with different currencies.");
        checked { return new Money(Amount + other.Amount, Currency); }
    }

    public Money Multiply(int factor)
    {
        if (factor < 0) throw new ArgumentOutOfRangeException(nameof(factor));
        checked { return new Money(Amount * factor, Currency); }
    }
}

// OrderLine is a Value Object (no identity, immutable, defined by its values).
public readonly record struct OrderLine(ProductId ProductId, Quantity Quantity, Money UnitPrice)
{
    public Money Total => UnitPrice.Multiply(Quantity.Value);

    public OrderLine Increase(Quantity additionalQuantity)
    {
        if (additionalQuantity.Value <= 0)
            throw new ArgumentOutOfRangeException(nameof(additionalQuantity));

        return this with { Quantity = Quantity.Increase(additionalQuantity.Value) };
    }
}

// Boundaries (Repositories / UoW)
public interface IOrderRepository
{
    void Save(Order order);
    Order? GetById(OrderId id);
}

public interface ICustomerRepository
{
    Customer? GetById(CustomerId id);
}

public interface IUnitOfWork : IDisposable
{
    ITransaction Begin();
}

public interface ITransaction : IDisposable
{
    void Commit();
}

// Just a placeholder for the example
public record Customer(CustomerId Id);

Repare como o serviço de aplicação cuida de fronteiras: valida parâmetros de entrada, abre a transação, busca o cliente, orquestra a criação do pedido e, por fim, persiste e confirma. Todo o miolo de negócio, invariantes, estados, cálculo, está encapsulado no agregado. É exatamente a relação que Evans defende entre Application Services e o modelo tático do DDD, enquanto Fowler chamaria isso de uma implementação limpa de Service Layer, e Uncle Bob veria um caso de uso independente de detalhes, pronto para ser testado com dublês de repositório e uma implementação in-memory de Unit of Work. Essa independência não é luxo: é o que permite evoluir tecnologia, dividir times por bounded contexts e manter o domínio respirando sem o cheiro da infraestrutura.

O que costuma dar errado é quando o Service Layer começa a “pensar pelo domínio”. A tentação aparece rápido: é fácil somar preços no serviço, validar estoque ali mesmo, chamar três APIs e consolidar o resultado. Funciona hoje, cobra caro amanhã. O serviço infla, fica difícil de ler, muda o tempo todo e os testes viram integrações frágeis. O caminho de volta é mover regra para onde ela pertence, dar nomes claros aos casos de uso, e aceitar que o serviço é o maestro, não a orquestra. É também nesse ponto que práticas de Refactoring (Fowler) e os hábitos de código limpo do Clean Code ajudam: funções pequenas, nomes que revelam intenção, eliminação de repetição e limites de responsabilidade bem desenhados. O benefício aparece na primeira refatoração sem medo e no primeiro teste que quebra pelo motivo certo.

Conclusão

No fim, o Service Layer não rouba o protagonismo do domínio; ele o protege. Dá uma rota única e explícita para cada história de negócio, documenta a coreografia do que acontece por dentro e deixa claro o contrato com o mundo de fora. Mantendo-o magro e expressivo, você ganha casos de uso testáveis, um modelo de domínio vivo e uma arquitetura que grita o que o sistema faz, não qual framework ele usa. No próximo capítulo, vamos descer a rampa para a persistência e encarar o Active Record: simples e sedutor, mas com implicações sérias quando visto sob a ótica do DDD e da Clean Architecture, e é nessa fricção que moram as melhores decisões.

Carregando publicação patrocinada...
4

Passando pra revisar depois de um tempo, não havia percebido da primeira vez a keyword checked. Eu particularmente nunca a havia visto, após uma pesquisa rápida descobri que é pra nâo dar overflow em tipos inteiros nos casos como int.MaxValue + 1, lançando uma exceção. Sabe se isso ainda é recomendado nos padrões modernos? Digo isso apenas por nunca ter visto hehe.

2

Fala Marlon tudo bem? Obrigado pelo comentário. O checked ainda existe e funciona normalmente, mas é totalmente esperado quase nunca vê-lo em código moderno. Na prática, a maioria dos sistemas evita esse problema antes, seja escolhendo tipos mais adequados (long, decimal), validando limites ou cobrindo os cenários com testes, o que torna o uso explícito de checked raro no dia a dia. Ele acaba sendo uma ferramenta pontual, útil em código muito sensível, como bibliotecas ou lógica financeira, onde overflow silencioso seria inaceitável, mas não algo que os padrões atuais incentivem usar de forma generalizada.

Espero ter ajudado. Abraço

1
1