Pular para o conteúdo principal
Engineering8 min de leitura

Arquitetura como Código com Structurizr DSL: Um Workspace C4 que Tanto o Revisor Quanto o Agente Conseguem Ler

Comitei um workspace Structurizr DSL ao lado de um serviço de billing em Kotlin/Spring e plugei o ArchUnit no Gradle, de modo que o CI quebra no instante em que o código diverge do desenho C4. Aqui está o workspace.dsl, o teste que o sustenta e o que mudou quando um agente de código passou a ler os dois arquivos em vez de adivinhar a partir dos pacotes.

Todos os Posts
2/4

Um diagrama de Confluence desenhado no dia em que um serviço nasceu vira fóssil já no segundo sprint. Eu tinha dois desses nas minhas anotações antigas — mesmas caixas, mesmas linhas, mesmas mentiras. O que me empurrou a arrancar tudo e tentar algo diferente foi um post de Neal Ford e Mark Richards publicado no O'Reilly Radar em abril de 2026, "Architecture as Code to Teach Humans and Agents About Architecture". O argumento deles é curto: os leitores de artefatos de arquitetura não são mais apenas humanos. Agentes de código agora consultam os mesmos diagramas nas mesmas pull requests, e intenção que vive fora do repo é intenção que nenhum dos dois leitores enxerga.

Então peguei um pequeno serviço Kotlin/Spring de um projeto descartável, apaguei a página dele no Confluence e comitei um único arquivo workspace.dsl na raiz do repo. Em seguida, adicionei um teste no Gradle que falha quando o bytecode deixa de bater com o arquivo. É disso que se trata o post inteiro: como esses dois artefatos se parecem e a lição que tirei do experimento.

O recado

Um arquivo Structurizr DSL no repo só vale mais que um diagrama de Confluence quando o CI falha na divergência entre o arquivo e o código. Arquitetura como código sem essa verificação é apenas diagramas como código, e prosa apodrecendo com sintaxe extra continua sendo prosa apodrecendo.

O diagrama abaixo é o loop que quero que o leitor visualize antes do resto do post — um arquivo DSL alimentando três consumidores, com um portão Gradle no fim que decide se a PR vai pra produção.

O workspace.dsl que comitei ao lado de um serviço de billing

O serviço é pequeno: uma camada HTTP, uma camada de domínio, uma porta para o banco, uma porta de saída para um gateway de pagamento externo. Quatro componentes, quatro relações permitidas, qualquer outra dependência proibida. O Structurizr DSL é um formato textual que mapeia para o modelo C4, roda na JVM e exporta para PlantUML e Mermaid via a ferramenta de linha de comando do Structurizr. O arquivo inteiro cabe em uma tela.

workspace "billing" "Billing service for a small marketplace" { model { customer = person "Customer" billing = softwareSystem "Billing" { api = container "API" "HTTP edge" "Spring Boot" { http = component "HttpController" "REST endpoints" "Kotlin" domain = component "BillingDomain" "Invoice + charge logic" "Kotlin" repo = component "InvoiceRepository" "Persistence port" "Kotlin" gw = component "PaymentGateway" "Outbound port" "Kotlin" } db = container "Postgres" "Invoices, charges" "PostgreSQL 16" } stripe = softwareSystem "Stripe" "Card processor" "External" customer -> http "Pays an invoice" http -> domain "Calls" domain -> repo "Reads + writes invoices" domain -> gw "Charges a card" repo -> db "SQL" gw -> stripe "HTTPS" } views { component api "Components" { include * autolayout lr } theme default } }

Renderize com a exportação de linha de comando do Structurizr para Mermaid e sai um diagrama C4 de componentes. PlantUML funciona da mesma forma. O arquivo é a fonte da verdade; a figura é um artefato de build.

Dois detalhes das minhas anotações vale destacar. Primeiro, toda relação é explícita. O DSL também aceita relações implícitas, mas eu desligo — uma seta faltando deve ser uma seta faltando, não um chute. Segundo, a tag de tecnologia em cada componente (Kotlin, Spring Boot, PostgreSQL 16) é onde um agente de código se agarra ao raciocinar sobre o arquivo. Uma caixa rotulada como "BillingDomain — Kotlin" ancora o modelo muito melhor do que "BillingDomain" sozinho.

A verificação no Gradle que mantém o arquivo honesto

O DSL declara que HttpController chega em BillingDomain, que BillingDomain chega em InvoiceRepository e PaymentGateway, e é só. Nada mais. O ArchUnit, biblioteca da JVM que importa bytecode compilado e impõe regras de dentro de qualquer teste JUnit, é a ferramenta mais barata que encontrei para garantir esse contrato. O guia do usuário do ArchUnit é direto sobre o ponto de integração: as regras rodam "em qualquer framework de teste unitário Java comum", o que se traduz limpo para JUnit 5 dentro de um build Gradle de um projeto Kotlin.

Um arquivo Kotlin, uma regra, montado em cima do JUnit 5:

kotlin
package com.example.billing

import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.library.Architectures.layeredArchitecture
import org.junit.jupiter.api.Test

class ArchitectureTest {
    @Test
    fun `code respects the workspace dsl`() {
        val classes = ClassFileImporter().importPackages("com.example.billing")

        layeredArchitecture().consideringAllDependencies()
            .layer("Http").definedBy("..http..")
            .layer("Domain").definedBy("..domain..")
            .layer("Repo").definedBy("..persistence..")
            .layer("Gateway").definedBy("..gateway..")
            .whereLayer("Http").mayNotBeAccessedByAnyLayer()
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Http")
            .whereLayer("Repo").mayOnlyBeAccessedByLayers("Domain")
            .whereLayer("Gateway").mayOnlyBeAccessedByLayers("Domain")
            .check(classes)
    }
}

Rode com ./gradlew test. As quatro cláusulas whereLayer são uma transcrição um-para-um das quatro setas do DSL. Adicione um @Autowired do Spring de HttpController direto para InvoiceRepository, pulando o domínio, e o teste falha na próxima rodada do CI com os nomes exatos das classes que quebraram a regra.

A versão que importou no meu setup: com.tngtech.archunit:archunit-junit5:1.4.2 como testImplementation no build.gradle.kts, mais as classes Kotlin compiladas que já estão no classpath de teste via o plugin padrão kotlin-jvm. Sem fiação extra. O ArchUnit lê arquivos .class compilados, então Kotlin ou Java é irrelevante quando o teste roda.

O que um agente de código tira do DSL que o código-fonte sozinho não dá

A resposta honesta: os verbos e as fronteiras. Quando coloquei o mesmo agente no repo com e sem o workspace.dsl, dois comportamentos mudaram nos meus testes.

Primeiro, o agente parou de inventar dependências. Sem o DSL, ao receber a tarefa de "ligar retentativas de pagamento", ele tentou adicionar um campo StripeClient direto em HttpController. Com o DSL no contexto, ele colocou a lógica de retry dentro de BillingDomain e roteou pela porta PaymentGateway existente. As setas no arquivo deixaram a fronteira visível de um jeito que pacotes Kotlin espalhados não deixavam.

Segundo, o agente nomeou o novo código de forma consistente. Os identificadores do DSL (http, domain, repo, gw) funcionam como glossário; o agente reusou esses substantivos em vez de cunhar sinônimos como service ou client. O código-fonte sozinho não anuncia a própria ontologia. Um DSL anuncia.

Nenhum desses efeitos é garantia. O agente ainda alucinou uma vez um componente "billing-events" que não existia em lugar nenhum. Mas o teste do ArchUnit pegou o commit resultante antes do review, que é justamente o ponto do segundo artefato.

Onde isso desmorona

Três armadilhas das minhas anotações, em ordem de quantas vezes caí em cada.

A primeira armadilha: o DSL vira shelfware no momento em que a regra do ArchUnit some ou é silenciada. Sem o teste, o arquivo se torna mais um diagrama, e adicionar caixas nele numa tarde de sexta não custa nada — que é exatamente por que nada impede a deriva. A verificação não é opcional.

A segunda armadilha: o ArchUnit lê bytecode, não intenção. Ele pega código que quebra uma seta declarada no DSL. Não pega uma seta declarada no DSL que nenhum código usa. Mantenho um pequeno script que lista identificadores do DSL sem cobertura de teste e os sinaliza no momento do review; o toolchain sozinho não resolve essa assimetria.

A terceira armadilha: introduzir as duas ferramentas em um repo brown-field de uma vez gera centenas de violações na primeira execução. O FreezingArchRule do ArchUnit foi feito para esse caso — ele grava as violações existentes em um arquivo de snapshot, falha apenas nas novas e encolhe o snapshot sempre que uma violação antiga é corrigida. Recorro a ele toda vez que a primeira execução imprime mais que um punhado de falhas.

Os limites que vale dizer em voz alta. Granularidade no nível de componente é onde o Structurizr DSL paga; descer para diagramas de classe transforma a coisa em UML de baixa resolução. Visões de deployment só valem ser comitadas quando a topologia de deploy é não-trivial. E se o serviço tem um container e três classes, o DSL é overhead — um diagrama no README basta.

Quando recorrer e quando pular

Recorra ao workspace.dsl mais uma regra ArchUnit quando o serviço tiver pelo menos três componentes lógicos, quando um agente de código abrir PRs no repo regularmente, ou quando mais de uma pessoa tiver introduzido uma dependência cross-layer "pequenininha" no último trimestre. Pule em protótipos, em bibliotecas de classe única e em serviços onde a arquitetura muda toda semana — nessa velocidade, o próprio DSL vira gargalo.

O exercício inteiro destilado em quatro linhas para o meu eu futuro:

  • Comite um workspace.dsl por bounded context, não por repo.
  • Espelhe cada seta do DSL como uma cláusula whereLayer no ArchUnit.
  • Desligue relações implícitas; force toda dependência a ser explícita.
  • Trate um ./gradlew test verde como única prova de que o diagrama está atualizado.

Quando o workspace.dsl e o teste do ArchUnit passam juntos, o diagrama na próxima PR é real. Quando algum deles falta, o diagrama é uma história — e histórias é o que eu tinha no Confluence.

Leitura adicional

  • Neal Ford e Mark Richards, "Architecture as Code to Teach Humans and Agents About Architecture", O'Reilly Radar, abril de 2026.
  • Documentação do Structurizr DSL em docs.structurizr.com/dsl.
  • Guia do usuário do ArchUnit em archunit.org/userguide.
Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.

Posts Relacionados

Engineering

Transformando Manutenção de Repositório em Markdown: Mantendo uma Codebase Rust Viva com Workflows Agênticos

Repositórios de vida longa sofrem drift: componentes depreciados persistem, camadas sangram e testes não cobrem as funções que de fato quebram. No meu próprio estudo, transformei três tarefas recorrentes em workflows markdown agendados que o próprio repositório roda sobre si mesmo, e depois escrevi o que aprendi sobre limitar o raio de impacto, parear checagens LLM com scans determinísticos, e deixar os agentes rascunharem formatos enquanto eu escrevo a substância.

Engineering

Uma Fitness Function É Só um Teste Que Quebra o Build Quando a Arquitetura Desvia

Uma fitness function não é um artefato de framework — é um teste que quebra o build e codifica um invariante arquitetural. Codifico uma regra de camadas em cerca de 60 linhas de TypeScript usando a própria API do compilador, testo o teste contra árvores boas, ruins e de código gerado, e então traço a linha entre um invariante que vale a pena travar e um gate de métrica que sai pela culatra sob a lei de Goodhart.