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

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
- 1.1 Transaction Script: do procedural ao domínio rico
- 1.2 Table Module: tabelas falam mais alto que objetos?
- 1.3 Domain Model: o coração da arquitetura em três vozes
- 1.4 Service Layer: orquestrando casos de uso
Parte 2 – Persistência e Infraestrutura
- 2.1 Active Record: simplicidade que pode custar caro
- 2.2 Data Mapper: separando domínio e banco de dados
- 2.3 Unit of Work: coordenando mudanças no domínio
- 2.4 Identity Map & Lazy Load: truques de performance e consistência
Parte 3 – Apresentação e Integração
- 3.1 MVC e Front Controller: a porta de entrada
- 3.2 Template View e Page Controller: quando a UI dita o jogo
- 3.3 Gateways e Mappers: defendendo o domínio
- 3.4 Mensageria e integração: eventos em três perspectivas
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.