Kaique Mitsuo Silva Yamamoto
Arquitetura software

SOLID na Pratica: Next.js e Spring Boot

Este material e um estudo aprofundado sobre como aplicar SOLID em sistemas reais, indo alem da definicao teorica. O foco e reduzir acoplamento, facilitar mudancas e aumentar testabilidade.


O que e SOLID

SOLID e um conjunto de 5 principios de design orientado a objetos:

  • SRP (Single Responsibility Principle): uma classe/modulo deve ter um unico motivo para mudar.
  • OCP (Open/Closed Principle): entidades devem estar abertas para extensao e fechadas para modificacao.
  • LSP (Liskov Substitution Principle): subtipos devem poder substituir o tipo base sem quebrar comportamento.
  • ISP (Interface Segregation Principle): prefira interfaces pequenas e especificas.
  • DIP (Dependency Inversion Principle): dependa de abstracoes, nao de implementacoes concretas.

Em TypeScript e Java, SOLID nao significa "muitas classes", e sim fronteiras claras entre regras de negocio e detalhes de framework.


Por que SOLID importa em arquitetura

Quando projetos crescem sem SOLID, os sintomas aparecem rapido:

  • Regras de negocio espalhadas em controllers/handlers.
  • Mudancas pequenas exigindo alteracoes em varios pontos.
  • Testes lentos, porque tudo depende de banco, fila, cache ou API externa.
  • Alto risco de regressao por falta de isolamento.

Com SOLID:

  • use cases ficam previsiveis;
  • infraestrutura vira detalhe substituivel;
  • testes de regra rodam sem framework;
  • evolucao para microservicos/modular monolith fica mais simples.

Como estruturar SOLID na pratica

Uma forma pragmatica e usar 4 blocos:

  1. Domain: entidades, regras, invariantes.
  2. Application: casos de uso (orquestracao de regras).
  3. Ports: contratos de entrada/saida (interfaces).
  4. Adapters: implementacoes concretas (DB, HTTP, cache, mensageria).

Mapeamento para Next.js

  • app/api/* ou server actions: adapter de entrada HTTP/UI.
  • src/core/application/*: casos de uso.
  • src/core/domain/*: entidades e regras.
  • src/core/ports/*: interfaces de repositorio/servicos.
  • src/infra/*: Prisma, Redis, fila, gateways externos.

Mapeamento para Spring Boot

  • controller: adapter de entrada.
  • application/usecase: casos de uso.
  • domain: entidades e servicos de dominio.
  • application/port/in e application/port/out: contratos.
  • infrastructure: JPA, clients HTTP, mensageria.

S - Single Responsibility Principle (SRP)

Regra pratica

Se um modulo muda por mais de um motivo (negocio + persistencia + transporte), SRP esta quebrado.

Exemplo ruim (Next.js)

// app/api/orders/route.ts
// Mistura validacao, regra de negocio, persistencia e notificacao
export async function POST(req: Request) {
  const body = await req.json()
  if (!body.items?.length) return Response.json({ error: "invalid" }, { status: 400 })

  const total = body.items.reduce((sum: number, item: any) => sum + item.price, 0)
  const order = await prisma.order.create({ data: { total } })
  await emailClient.send({ to: body.email, subject: "Pedido criado" })

  return Response.json(order)
}

Exemplo melhor (Next.js)

// app/api/orders/route.ts
import { createOrderUseCase } from "@/core/application/create-order"
import { prismaOrderRepository } from "@/infra/repositories/prisma-order-repository"
import { resendEmailGateway } from "@/infra/gateways/resend-email-gateway"

export async function POST(req: Request) {
  const input = await req.json()
  const useCase = createOrderUseCase(prismaOrderRepository, resendEmailGateway)
  const output = await useCase.execute(input)
  return Response.json(output, { status: 201 })
}

SRP aqui: route adapta HTTP, use case orquestra negocio, repositorio persiste, gateway notifica.

Exemplo melhor (Spring Boot)

@RestController
@RequestMapping("/orders")
class OrderController {
  private final CreateOrderUseCase useCase;

  OrderController(CreateOrderUseCase useCase) {
    this.useCase = useCase;
  }

  @PostMapping
  ResponseEntity<OrderOutput> create(@RequestBody CreateOrderInput input) {
    return ResponseEntity.status(201).body(useCase.execute(input));
  }
}

O - Open/Closed Principle (OCP)

Regra pratica

Adicionar comportamento novo sem editar codigo estavel.

Caso comum: descontos por tipo de cliente

Em vez de if/else gigante, use estrategia:

export interface DiscountPolicy {
  supports(type: string): boolean
  calculate(total: number): number
}

export class VipDiscount implements DiscountPolicy {
  supports(type: string) { return type === "VIP" }
  calculate(total: number) { return total * 0.85 }
}

export class RegularDiscount implements DiscountPolicy {
  supports(type: string) { return type === "REGULAR" }
  calculate(total: number) { return total * 0.95 }
}

export class DiscountService {
  constructor(private readonly policies: DiscountPolicy[]) {}

  apply(customerType: string, total: number): number {
    const policy = this.policies.find((p) => p.supports(customerType))
    return policy ? policy.calculate(total) : total
  }
}

Novo tipo de cliente: criar nova policy, sem alterar DiscountService.

No Spring Boot, o mesmo padrao funciona com injecao de List<DiscountPolicy>.


L - Liskov Substitution Principle (LSP)

Regra pratica

Se uma implementacao forcada muda pre-condicoes, pos-condicoes ou excecoes esperadas, ela quebra LSP.

Exemplo classico

  • PaymentGateway.charge(...) promete cobrar.
  • SandboxPaymentGateway nao pode "fingir sucesso" sem contrato explicito.

Boa pratica:

  • definir contrato com semantica clara;
  • separar ambiente real/sandbox por configuracao;
  • testar contrato com suite compartilhada (contract tests).

I - Interface Segregation Principle (ISP)

Regra pratica

Interfaces gordas forcam implementacoes a conhecer detalhes que nao usam.

Exemplo

Ruim:

interface UserRepository {
  save(user: User): Promise<void>
  findById(id: string): Promise<User | null>
  exportCsv(): Promise<string>
  sendMarketingEmail(): Promise<void>
}

Melhor:

interface UserWriter {
  save(user: User): Promise<void>
}

interface UserReader {
  findById(id: string): Promise<User | null>
}

No Spring Boot: separar UserQueryPort de UserCommandPort.


D - Dependency Inversion Principle (DIP)

Regra pratica

Casos de uso dependem de portas, nao de bibliotecas/framework.

Exemplo em Next.js

export interface OrderRepository {
  save(order: Order): Promise<void>
}

export interface NotificationGateway {
  notifyOrderCreated(email: string): Promise<void>
}

export function createOrderUseCase(
  orderRepository: OrderRepository,
  notificationGateway: NotificationGateway,
) {
  return {
    async execute(input: CreateOrderInput) {
      const order = Order.create(input)
      await orderRepository.save(order)
      await notificationGateway.notifyOrderCreated(input.email)
      return { id: order.id }
    },
  }
}

Esse use case roda em teste sem Prisma, sem HTTP, sem Next.js.

Exemplo em Spring Boot

public interface OrderRepositoryPort {
  void save(Order order);
}

public interface NotificationPort {
  void notifyOrderCreated(String email);
}

@Service
public class CreateOrderUseCase {
  private final OrderRepositoryPort orderRepository;
  private final NotificationPort notification;

  public CreateOrderUseCase(OrderRepositoryPort orderRepository, NotificationPort notification) {
    this.orderRepository = orderRepository;
    this.notification = notification;
  }

  public OrderOutput execute(CreateOrderInput input) {
    Order order = Order.create(input);
    orderRepository.save(order);
    notification.notifyOrderCreated(input.email());
    return new OrderOutput(order.getId());
  }
}

Exemplo de estrutura de pastas

Next.js (App Router + TypeScript)

src/
  app/
    api/orders/route.ts
  core/
    domain/order.ts
    application/create-order.ts
    ports/order-repository.ts
    ports/notification-gateway.ts
  infra/
    repositories/prisma-order-repository.ts
    gateways/resend-notification-gateway.ts

Spring Boot (Hexagonal simplificada)

src/main/java/com/acme/orders/
  domain/Order.java
  application/usecase/CreateOrderUseCase.java
  application/port/in/CreateOrderInputPort.java
  application/port/out/OrderRepositoryPort.java
  infrastructure/web/OrderController.java
  infrastructure/persistence/JpaOrderRepositoryAdapter.java
  infrastructure/notification/EmailNotificationAdapter.java

Roteiro de adocao em projeto legado

  1. Identifique um fluxo de negocio critico (ex.: criar pedido).
  2. Extraia um use case unico para este fluxo.
  3. Defina portas de saida (repositorio, fila, email).
  4. Implemente adapters sem alterar regra de negocio.
  5. Cubra o use case com testes unitarios de regra.
  6. Repita para proximos fluxos.

Nao tente "solidificar" tudo de uma vez. Evolucao incremental reduz risco.


Armadilhas comuns

  • Criar interfaces para tudo sem necessidade real.
  • Confundir arquitetura limpa com excesso de camadas.
  • Colocar validacao de regra de negocio apenas no controller.
  • Fazer mocks demais e ignorar contract/integration tests.
  • Tratar SOLID como checklist, e nao como criterio de design.

Checklist rapido de revisao

  • O caso de uso executa sem framework?
  • Mudanca de banco/API externa exige alterar regra de negocio?
  • Ha if/else crescendo para variacoes de comportamento?
  • Interfaces estao pequenas e focadas?
  • Existe teste de contrato para adapters criticos?

Se 3 ou mais respostas forem "nao", existe alto ganho ao aplicar SOLID.


Recursos recomendados

Canais no YouTube

  • Continuous Delivery - design, arquitetura e clean code.
  • Amigoscode - Spring Boot, design orientado a dominio e boas praticas.
  • Java Brains - fundamentos de arquitetura e Spring.
  • Vercel - arquitetura moderna no ecossistema Next.js.
  • Fireship - explicacoes objetivas de conceitos e stack web.

Videos recomendados

Artigos e documentacao

Livros


Conclusao

SOLID funciona melhor quando aplicado a fluxos reais de negocio, com fronteiras claras entre regra, contrato e infraestrutura. Em Next.js e Spring Boot, o ganho vem de deixar framework como detalhe e manter o nucleo da aplicacao simples, testavel e evolutivo.