Functional Programming
DDD
NodeJS
Como refatorei o Notion Blog: uma arquitetura funcional escalável com Node.js, Express e DDD
Transformei o notion-blog com uma arquitetura funcional em Node.js e Express, inspirada no DDD. Agora o código tá mais limpo, testável e preparado pra crescer.
Última atualização realizada em:
Desenvolver a parte inicial do código do notion-blog foi relativamente simples até aqui. Basicamente, instanciei um servidor HTTP com Express, escrevi o código das rotas diretamente na composição do framework (que por enquanto são poucas) e conectei o consumo entre as APIs do Notion e da Vercel. Nada muito complexo. Creio que todos concordamos, certo?
O verdadeiro desafio começa quando olhamos pra frente. Em algum momento, essa ferramenta pode evoluir e passar a incluir novas funcionalidades — como contador de leituras, tempo estimado de leitura, newsletter e outras integrações.
À medida que o código cresce, a complexidade também aumenta. Testar, depurar e manter o sistema se tornam tarefas mais difíceis e, inevitavelmente, frustrantes. É exatamente nesse ponto que muitos pequenos projetos acabam sendo abandonados: a dificuldade de adicionar pequenas funcionalidades acaba tornando o processo pesado e desmotivador.
Tabela de conteúdos
Objetivos da RefatoraçãoImplementando modificaçõesIntroduzindo o paradigma funcionalImplementando um Handler puroGanhos reais dessa modificação estrutural1. Substituição de exceções por resultados explícitos2. Inversão de dependências via Reader3. Functional Core, Imperative Shell4. Ports & Adapters (Arquitetura Hexagonal)5. Introdução de interfaces e contratos genéricos6. Isolamento de efeitos assíncronos7. Padronização e composição funcional8. Separação de responsabilidades9. Documentação de domínio via tiposEm resumoAplicando conceitos do DDDInterface RepositoryImplementando RepositoriesIn Memory RepositoryTransformando o acesso a NotionAPI através do padrão RepositoryBenefício diretoRoadmap e próximos passosConclusão
Você pode conferir o projeto completo no repositório do projeto
Refatorar o código da aplicação não é um mero exercício de demonstração técnica. É uma necessidade — um passo fundamental para garantir a evolução e a manutenabilidade do sistema.
Como esse é um projeto em que eu defino as diretrizes, adoto a programação funcional como base. Acredito que esse paradigma torna o código mais previsível, testável e seguro. Se analisarmos os testes que já escrevemos até aqui, percebemos que precisamos recorrer a muitos mocks — substituições de bibliotecas acopladas e dependentes dentro do nosso pequeno sistema. Isso é um claro sinal de que podemos melhorar o desenho da aplicação.
Objetivos da Refatoração
- Diminuir o acoplamento do código (reduzir dependências diretas)
- Tornar o código mais limpo e legível
- Dividir responsabilidades de forma clara
- Aplicar um padrão funcional que facilite a colaboração entre desenvolvedores
Implementando modificações
Introduzindo o paradigma funcional
Abaixo temos um exemplo de código atual da aplicação. Basicamente, toda a lógica acontece dentro da estrutura do
requestHandler do framework Express. Isso significa que, se tentarmos modificar qualquer parte, há uma boa chance de quebrar a aplicação.app.get('/post/:slug', async (req, res) => { try { const { slug } = req.params const pageId = await getPostIdWithSlug({ slug }) if (!pageId) return res.status(400).json({ message: 'Bad request' }) const page = await getPostData({ pageId }) if (!page) return res.status(404).json({ message: 'Post not found' }) return res.json(page) } catch (e) { // eslint-disable-next-line no-console console.error(e) return res.status(500).json({ error: 'Internal server error' }) } })
O que a programação funcional nos diz sobre funções puras?
Funções puras não apresentam side effects e, justamente por isso, são previsíveis e testáveis. É nesse caminho que começa a refatoração: transformar o handler acima em uma função pura.
Para isso, foram criadas as seguintes interfaces e tipos:
export interface Tagged<V extends string> { readonly __tag: V } export interface Request<B = any> extends Tagged<'request'> { ctx: Context body: B headers: IncomingHttpHeaders } export interface Response<B = any> extends Tagged<'response'> { status: number body: B } export interface Reader<E, A> { (env: E): A } export interface ReaderTask<E, A> { (env: E): Promise<A> } export type Result<E, A> = Reader<E, A> | ReaderTask<E, A> export type HandlerResult<E> = Result<E, Response> interface Handler<E = {}> extends Tagged<'handler'> { (request: Request): HandlerResult<E> }
Implementando um Handler puro
interface Env { covers: Readable<Repository<PostCover>> posts: Queryable<Repository<Post>> } export const getPostDataHandler = Handler( request => async ({ covers, posts }: Env) => { const { slug } = request.ctx.params const r1 = await getPostIdWithSlug({ slug })({ covers }) if (isLeft(r1)) return Response({ status: 400, body: { message: 'Bad request' } }) const r2 = await getPostData({ id: r1.value })({ posts }) if (isLeft(r2)) return Response({ status: 404, body: { message: 'Post not found' } }) return Response.ok(r2.value) }, )
Para que esse handler funcione dentro do framework Express, criamos uma camada adaptadora:
export function expressHandlerAdapter<E = {}>( handler: Handler<any>, env: E = {} as E, ): RequestHandler { return (request, response) => { try { const res = handler(requestAdapter(request))(env) if (res instanceof Promise) { return res.then(r => response.status(r.status).json(r.body)) } return response.status(res.status).json(res.body) } catch (e) { console.error(e) return response.status(500).json({ message: 'Internal server error!' }) } } }
Com essa modificação, se os handlers forem bem escritos — limpos e sem dependências diretas —, as funções tornam-se puras, o que facilita a previsibilidade, testabilidade e composição.
Ganhos reais dessa modificação estrutural
A refatoração aplicada vai muito além da mudança estética do código.
Ela introduz conceitos da programação funcional que tornam o sistema mais seguro, previsível e sustentável.
A seguir estão os principais ganhos obtidos — e o que cada um representa na prática.
1. Substituição de exceções por resultados explícitos
Em vez de lidar com erros através de blocos
try/catch, a aplicação agora trabalha com tipos de resultado explícitos (Result<A> = FailedResult | SuccessfulResult<A>).Essa abordagem segue o modelo Railway-Oriented Programming, no qual fluxos de sucesso e erro são componíveis e tipados.
O resultado é um tratamento de erros mais seguro e legível, sem dependência de exceções em tempo de execução.
2. Inversão de dependências via Reader
A função
Handler<E, A> passa a retornar um Reader (ou ReaderTask), que injeta o ambiente de dependências (E) no momento da execução.Isso significa que a função deixa de criar suas próprias dependências — elas são fornecidas externamente.
Esse padrão reflete o princípio de Inversão de Controle, tornando o código mais modular, testável e flexível a mudanças.
3. Functional Core, Imperative Shell
A lógica de negócio (core) é agora totalmente pura, sem efeitos colaterais.
A parte imperativa — responsável por lidar com requisições HTTP, status e respostas JSON — fica isolada no adapter do Express.
Esse modelo (core funcional, shell imperativo) garante que o domínio possa ser testado de forma independente, mantendo os efeitos colaterais apenas nas bordas do sistema.
4. Ports & Adapters (Arquitetura Hexagonal)
O
expressHandlerAdapter funciona como um adaptador entre o mundo HTTP e o domínio funcional.Com essa camada intermediária, o núcleo da aplicação se torna agnóstico ao framework, permitindo futuras migrações (por exemplo, trocar Express por Fastify) sem grandes impactos no código principal.
5. Introdução de interfaces e contratos genéricos
A criação das interfaces
Handler<E, A> e HandlerResult<E, A> estabelece contratos claros para comunicação entre módulos.Essa formalização reduz o acoplamento entre componentes e facilita a escrita de mocks e stubs para testes, além de documentar de forma explícita o comportamento esperado de cada função.
6. Isolamento de efeitos assíncronos
Ao utilizar
ReaderTask, os efeitos assíncronos (como chamadas de API ou banco de dados) são descritos, e não imediatamente executados.Essa separação permite que o código seja testado de forma determinística, já que os efeitos só ocorrem quando explicitamente rodados pelo adapter.
7. Padronização e composição funcional
Com
Result + Reader(Task), o código passa a ser componível — ou seja, funções podem ser encadeadas sem gerar pirâmides de try/catch ou múltiplos if/else.Essa padronização cria um fluxo de dados mais limpo, previsível e fácil de manter.
8. Separação de responsabilidades
Cada parte da aplicação agora tem uma responsabilidade única:
- O handler descreve o caso de uso (domínio puro)
- O adapter lida com transporte e protocolo HTTP
- O ambiente
Eagrupa dependências externas (como DB, cache ou APIs)
Essa divisão torna o sistema mais modular e escalável, simplificando manutenção e evolução.
9. Documentação de domínio via tipos
Os tipos
FailedResult e SuccessfulResult representam de forma explícita os estados possíveis de uma operação.Isso funciona como uma documentação viva do domínio, validada pelo compilador.
O desenvolvedor entende rapidamente o comportamento esperado de cada função, sem precisar mergulhar no código interno.
Em resumo
Essa abordagem funcional não só melhora a testabilidade e previsibilidade do sistema, como também o prepara para crescer de forma sustentável.
A aplicação passa a ter uma arquitetura mais clara, mais segura e menos dependente de frameworks — exatamente o tipo de fundação que diferencia projetos que evoluem daqueles que se tornam díficeis de manter.
Aplicando conceitos do DDD
Ao invés de implementar uma aplicação completamente alinhada ao Domain-Driven Design (DDD) e todas as suas camadas e elementos, optei por incorporar apenas os conceitos que fazem sentido dentro do paradigma funcional e que contribuem para a organização e reutilização do código.
A ideia não é seguir o DDD à risca, mas aproveitar alguns de seus princípios — especialmente aqueles que fortalecem o isolamento de domínio, a clareza dos contratos e a testabilidade das partes críticas do sistema.
Interface Repository
O primeiro conceito aplicado foi o padrão Repository.
Ele abstrai o acesso aos dados e permite trocar implementações externas (como APIs ou bancos de dados) por versões em memória — algo essencial para testes unitários previsíveis e sem dependência de serviços externos.
Veja o trecho de código abaixo:
export const URI = 'repository' export type URI = typeof URI export type RepositoryResult<E> = E | Promise<E> export interface Repository<E extends Entity = any> extends Tagged<URI> { readonly get: (id: string) => RepositoryResult<E | undefined> readonly set: (entity: E) => RepositoryResult<E> readonly remove: (e: Identifier) => RepositoryResult<void> readonly query: (q: Query<E>) => RepositoryResult<E[]> } // Helper Types export interface Readable<R extends Repository> extends Tagged<URI> { readonly get: R['get'] } export interface Queryable<R extends Repository> extends Tagged<URI> { readonly query: R['query'] }
Com essa estrutura, a aplicação passa a trabalhar com interfaces genéricas e previsíveis.
Isso facilita o isolamento de responsabilidades e torna o código agnóstico à fonte de dados, mantendo a pureza funcional e reduzindo o acoplamento.
Implementando Repositories
In Memory Repository
Abaixo está a implementação de um repositório em memória — útil para testes, prototipação e validação rápida de comportamento, sem necessidade de integração com o Notion ou qualquer outro backend.
export function InMemoryRepository<E extends Entity>({ repository = [], idProvider = Id(), }: Config<E> = {}): Repository<E> { let repo = [...repository] function get(id: string) { return repo.find(el => el.id === id) } // Create or Save function set(entity: E) { const now = new Date() const e = { ...entity } as any if (!entity.id) { e.id = idProvider() e.created_at = now } else { repo = repo.filter(el => el.id !== e.id) } e.updated_at = now repo = repo.concat(e) return e } function remove({ id }: Identifier) { repo = repo.filter(el => el.id !== id) } function query({ where, sorts, cursor, limit }: Query<E>) { let r = [...repo] if (sorts) r.sort(applySorts(sorts)) if (where) r = r.filter(applyWhere(where)) if (limit) { const start = cursor ? Number(cursor) * limit : 0 const end = cursor ? (Number(cursor) + 1) * limit : limit r = r.slice(start, end) } return r } return applyTag('repository')({ get, set, remove, query, }) }
Essa implementação simplifica drasticamente a substituição de dependências durante testes unitários, permitindo simular comportamentos de forma controlada e previsível.
Transformando o acesso a NotionAPI através do padrão Repository
O mesmo padrão também é aplicado ao acesso à API do Notion.
Em vez de espalhar chamadas diretas à API, tudo passa a ser mediado por um NotionRepository, que implementa apenas os métodos necessários.
Você pode conferir a implementação completa no repositório do projeto
Como o acesso atual à API se limita a queries, a implementação utiliza o helper type
Queryable<Repository<E>>:export function NotionRepository<E extends Entity>({ auth_token, collection_id, converter, propertyMapper, client = auth => new Client({ auth, }), }: Config<E>): Queryable<Repository<E>> { const notion = client(auth_token) const query = async ({ where, limit, sorts: s }: Query<E>) => { const filter = where ? filterMapper({ whereAdapter: whereAdapter(propertyMapper), })(where) : undefined const sorts = s ? sortMapper({ propertyMapper })(s) : undefined const result = await notion.dataSources.query({ data_source_id: collection_id, filter, sorts, page_size: limit, }) return result.results .filter(p => p.object === 'page' && !(p as any).in_trash) .map(converter.from) } return applyTag('repository')({ query, }) }
Benefício direto
Essa adaptação do padrão Repository dentro de um contexto funcional traz uma vantagem clara: é possível testar e evoluir o domínio sem depender de nenhuma infraestrutura externa.
O domínio se torna puro e previsível, enquanto os adapters (como o NotionRepository) ficam responsáveis por lidar com o mundo externo.
Roadmap e próximos passos
- Melhorar o design para refletir um blog mais moderno e consistente.
- Adicionar scripts de monitoramento de performance do conteúdo.
- Utilizar uma ferramenta de bundle para otimizar o tamanho da aplicação dentro do monorepo.
- Refatorar o código para desacoplar módulos e preparar a aplicação para futuras substituições sem grandes impactos.
Conclusão
Refatorar o código do notion-blog foi mais do que uma iniciativa técnica — foi um exercício de amadurecimento de arquitetura, propósito e visão de longo prazo. A decisão de adotar o paradigma funcional e princípios inspirados em DDD não surgiu de uma necessidade imediata, mas da compreensão de que a sustentabilidade de um projeto nasce da clareza de suas bases.
Ao transformar o código em algo mais previsível, isolado e seguro, criamos um ambiente fértil para evolução. Cada novo módulo, integração ou funcionalidade poderá ser incorporado sem comprometer o todo — uma das maiores conquistas em qualquer sistema em crescimento.
Essa refatoração representa um ponto de inflexão: deixamos de ver o notion-blog apenas como uma integração entre APIs e passamos a tratá-lo como um sistema com domínio próprio, com regras claras, camadas bem definidas e espaço para colaboração.
Mais do que código, esse processo reforça uma filosofia:
construir software não é apenas resolver problemas técnicos, mas criar estruturas que resistem ao tempo, às mudanças e às pessoas.
O que vem a seguir — o roadmap — não é apenas uma lista de tarefas, mas uma continuação natural desse processo. Melhorias de design, monitoramento, otimização de build e desacoplamento de módulos são extensões diretas do que foi estabelecido aqui: uma base sólida para evoluir com confiança.
Em resumo:
A refatoração do notion-blog não é um ponto final.
É o início de uma nova etapa — mais madura, mais consciente e muito mais preparada para crescer.