NodeJS
MongoDB
Functional Programming
Integração de APIs
ETL

Migração Estratégica de Dados: Implementando ETL e Repositório Local com Node.js e MongoDB

Descubra como implementei um pipeline ETL para espelhar dados da Notion API em MongoDB, reduzindo latência e aumentando a autonomia do backend.

Última atualização realizada em:
Imagem do autor do blog Yago Marinho
Por Yago Marinho
💡
Você pode conferir o projeto completo no repositório do projeto
A proposta do meu blog pessoal sempre foi — e continua sendo — compartilhar conhecimento técnico, experiências profissionais e aprendizados sobre tomada de decisões no desenvolvimento de software. Embora o código atual já me permita cumprir essa promessa inicial, seguir evoluindo cada módulo da aplicação é o que vai tornar essa plataforma algo realmente único e, potencialmente, incrível.

Tabela de conteúdos

Hoje, o frontend está bem servido: o Next.js entrega todo o conteúdo via CDN, garantindo velocidade e alta performance para quem acessa o blog. Porém, os dados fornecidos pela API ainda carecem de estrutura. Isso acontece porque eles vêm diretamente da Notion API, e o backend em Node.js funciona apenas como um intermediário — uma ponte que repassa dados externos e dispara atualizações para o app quando necessário.
Essa arquitetura, apesar de simples, cumpre seu papel: facilita minha escrita (que já faz parte do meu workflow diário) e mantém o blog rápido. Mas, quando pensamos em melhorar a experiência do usuário com novas funcionalidades, fica claro que o backend precisa dar um passo significativo em direção à robustez. Felizmente, já iniciei uma refatoração com foco em desacoplamento e boas práticas de mercado, o que torna essa jornada mais segura e previsível.

Problemas Reais e Implicações Técnicas

Vamos aos pontos críticos que motivaram essa evolução:
  • Dependência total da Notion API: Hoje, meu blog consome exatamente a estrutura que o Notion expõe. Se amanhã essa API mudar o formato de resposta, alterar regras de acesso ou limitar endpoints, o conteúdo do blog fica comprometido. Isso significa que parte do meu produto depende diretamente de uma decisão de negócio externa. E isso nunca é um bom sinal a longo prazo.
  • Tempo de resposta prejudicado: A cada requisição de conteúdo, o backend precisa buscar os dados no Notion primeiro — um caminho longo, sujeito a latência e instabilidade de rede.
    • Em um ambiente onde o usuário quer tudo praticamente instantâneo, isso se torna um gargalo para performance e SEO.
  • Dados majoritariamente estáticos → falta de cache: Conteúdos de blog raramente mudam. Faz todo sentido que estejam armazenados e servidos de forma otimizada, sem depender de chamadas externas a cada acesso.
  • Dificuldade para evoluir com novas funcionalidades: Recursos aparentemente simples, como adicionar um sistema de curtidas, tornam-se complexos quando os dados não estão no seu controle. Por exemplo:
    • Escrever as curtidas diretamente no Notion permaneceríamos reféns do problema nº 1
    • Criar um banco paralelo só para curtidas? Espalharia responsabilidades demais
    • Quando não há um modelo de dados próprio, cada feature vira uma gambiarra arquitetural.
Ou seja: os desafios atuais se refletem em performance, escalabilidade, governança dos dados e, principalmente, capacidade de evoluir o produto sem amarras externas.

Solução Proposta: Pipeline ETL + Banco Local (MongoDB)

Todos os pontos anteriores me levaram a uma decisão estratégica: assumir o controle do meu próprio conjunto de dados. Para isso, passei a armazenar localmente todos os conteúdos consumidos da Notion API — com um pipeline de ETL responsável por manter essas informações atualizadas, estruturadas e acessíveis de forma rápida.
Além de resolver os problemas citados, essa abordagem abre espaço para recursos adicionais, novas relações entre dados, métricas internas e funcionalidades que dependem de um modelo próprio de persistência.

O que é o ETL?

ETL é um processo de integração de dados que permite coletar informações de uma ou várias fontes, tratá-las conforme as necessidades do negócio e armazená-las em um sistema sob nosso controle.
Em outras palavras, o ETL garante que dados separados, despadronizados ou incompletos sejam convertidos em algo coeso, consistente e pronto para uso, sem depender da estrutura original da fonte externa.

Como funciona esse algoritmo?

A sigla ETL vem de:
  • Extract (extrair): Coletar dados das fontes externas — no meu caso, da Notion API.
  • Transform (transformar) Padronizar os campos, enriquecer o conteúdo, limpar o que não é necessário e adaptar ao modelo interno.
  • Load (carregar): Salvar esses dados já tratados em um banco de dados local (no meu caso MongoDB).
Um exemplo simples de aplicação de ETL fora do meu contexto:
Imagine que você tem dados em uma planilha do Google (CSV) e quer exibi-los como JSON no seu site.
O ETL faria a leitura do CSV, organizaria os valores no formato desejado e os armazenaria localmente para consumo rápido.
Ou seja: a API deixa de ser apenas uma ponte e passa a ser a fonte principal e confiável dos meus dados.

Implementando o Código

💡
Você pode conferir o projeto completo no repositório do projeto
Depois de entender o valor dessa arquitetura e como o ETL atua nos bastidores, chegou a hora de visualizar como isso tudo se traduz em código.

Repository Pattern: O Core do ETL

O coração do algoritmo ETL é a manipulação de dados. Ele precisa extrair informações de uma ou mais fontes, transformá-las e, em seguida, carregá-las em outro local.
Naturalmente, isso envolve operações diretas sobre repositórios — e é aí que entra o Repository Pattern.
Esse padrão é essencial para manter o código organizado, desacoplado e extensível. Ele abstrai o acesso aos dados, permitindo que o algoritmo ETL trabalhe de forma genérica, sem depender da implementação específica de cada fonte (por exemplo, Notion API, MongoDB, ou qualquer outro sistema de armazenamento).
Com o Repository Pattern, mudar o backend de dados (por exemplo, trocar o MongoDB por Postgres, ou substituir a API do Notion por outro CMS) se torna uma tarefa muito mais simples, já que o algoritmo continua o mesmo — apenas a implementação do repositório muda.
Todo repositório de dados precisa, no mínimo, oferecer as três operações básicas:
  • Leitura (get/query) – recuperar entidades ou coleções;
  • Escrita (set/batch) – criar ou atualizar registros;
  • Remoção (remove) – excluir entidades existentes.
A partir dessas operações básicas, é possível construir comportamentos mais complexos e compor funções específicas para o fluxo do ETL.
Veja abaixo a definição da interface base utilizada no projeto:
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[]> readonly batch: (b: Batch<E>) => RepositoryResult<WriteBatchResult> }
💡
Você pode conferir o padrão repository no repositório do github
Essa interface define a contrato mínimo que qualquer repositório deve seguir. Dessa forma, o ETL pode interagir com diferentes fontes de dados sem saber detalhes de implementação — ele apenas “fala” com o contrato.

#Case 1: ETL Post Data - Quando Webhook é Acionado

Dentro da arquitetura atual, existem dois cenários que exigem o acionamento do algoritmo ETL para atualização dos dados. O primeiro acontece quando o webhook do Notion é disparado, informando ao sistema que algum conteúdo foi modificado.
Nesse fluxo, o Notion envia um evento que contém o ID da página atualizada. Com base nesse ID, o ETL realiza uma extração direcionada dos dados externos para identificar qual ação tomar: atualizar o conteúdo local, criar um novo post ou remover o dado se ele tiver sido apagado no Notion.
Veja abaixo o algoritmo ETL Post Data implementado:
export const ETLPostData = Service<Request, Env, void>( ({ id }) => async ({ posts, props, contents }) => { // Extrair dados referentes ao ID informado const [properties, content, exists] = await Promise.all([ props.get(id), contents.get(id), posts.query(Query.where('external_ref', '==', id)), ]) const [post] = exists // Transformar os dados e decidir a intenção da ação const intent = decideIntent({ properties, content, post }) if (intent.kind === 'none') return Left({ message: 'intent.kind none' }) // Gravar o efeito no repositório alvo else if (intent.kind === 'remove') await posts.remove(intent.data) else if (intent.kind === 'upsert') await posts.set(intent.data) return Right() }, )
 
💡
Você pode conferir o algoritmo ETL Post Data no repositório do github
Principais pontos positivos desse fluxo:
  • Extração paralela → menos latência e maior rendimento
  • Processamento direcionado → o ETL só toca nos dados afetados
  • Decisão inteligente de ação → elimina operações desnecessárias
  • Independência da estrutura externa → quem dita as regras é o modelo local
  • Consistência garantida → o banco local sempre reflete o estado atual da fonte
Esse caso ilustra o comportamento reativo do pipeline: sempre que algo muda no Notion, o conteúdo local é atualizado imediatamente.

#Case 2: ETL Reconciler - Sincronização Periódica

O segundo caso de execução do ETL acontece de forma periódica. Isso é essencial porque, mesmo com o webhook, podem existir atualizações que não foram notificadas pela Notion API — seja por falhas no evento, delays de processamento ou mudanças não relacionadas ao webhook.
Para evitar qualquer divergência entre os dados externos e os dados armazenados localmente, o Reconciler entra em ação, garantindo que tudo permaneça sincronizado e íntegro.
Diferente do ETL reativo do Case 1, aqui o fluxo é proativo:
o próprio sistema dispara esse processo para manter a consistência completa do banco local.
Veja abaixo o algoritmo ETL Reconciler implementado:
export const ETLReconciler = Service( () => async ({ posts, props, contents }: Env) => { /* * Buscar as informações do repositório do NotionAPI * Buscar as informações do repositório de Posts */ const [listProps, listPosts] = await Promise.all([ props.query(), posts.query(), ]) // Fazer uma listagem única de external_ref presente nos dois sistemas (notionAPI e PostRepository) const uniqueRefs = [ ...new Set( listProps .map(prop => prop.id) .concat(listPosts.map(post => post.external_ref)), ).values(), ] // Criar uma lista de intent.kind para cada external_ref único const intents = await Promise.all( uniqueRefs.map(async external_ref => { const post = listPosts.find(p => p.external_ref === external_ref) const properties = listProps.find(p => p.id === external_ref) const content = properties ? await contents.get(external_ref) : undefined const intent = decideIntent({ properties, content, post }) return { id: external_ref, intent, } }), ) // Mapear a transformação de acordo com cada intent.kind const batch = intents .filter(intent => intent.intent.kind !== 'none') .map(intent => ({ type: intent.intent.kind, data: intent.intent.data, })) as Batch<Post> if (!batch.length) return Left({ message: 'The reconciler is not necessary' }) // Realizar um batch no repositório de Posts const result = await posts.batch(batch) if (result.status === 'failed') return Left({ message: 'Batch failed' }) return Right() }, )
💡
Você pode conferir o algoritmo ETL Reconciler no repositório do github
Principais benefícios desse fluxo:
  • Alta confiabilidade → Nenhuma mudança externa permanece não identificada.
  • Correção de inconsistências → Se algo falhar no webhook, o Reconciler corrige depois.
  • Execução otimizada em lote → Menos chamadas desnecessárias e maior rendimento do banco.
  • Controle total da integridade do modelo → O estado do sistema passa a ser previsível e sempre coerente.
  • Escalabilidade da governança dos dados → Quanto mais o blog crescer, mais importante se torna ter dados sob domínio próprio.

A Fase de Transformação

A fase de transformação é onde o valor real do ETL acontece. Aqui, os dados brutos vindos da Notion API — muitas vezes incompletos ou não estruturados — passam a ter forma, semântica e regras de negócio aplicadas. É nesta etapa que decidimos:
  • O dado deve ser criado no banco local?
  • O dado já existe e precisa ser atualizado?
  • O dado foi removido ou está inválido no Notion e deve ser excluído localmente?
  • Ou simplesmente nada mudou?
Em outras palavras: além de normalizar os dados, a transformação também determina a intenção da operação no repositório local.
A função decideIntent reúne todo o raciocínio descrito acima:
export function decideIntent({ properties, content, post, }: { properties?: ExtractedPostProps content?: ExtractedPostContent post?: Post }): IntentResult { // Se faltar qualquer tipo de dados externo, pode significar // remoção ou dados incompletos if (!properties || !content) return post ? kind('remove', { id: post.id }) : kind('none') // Caso o post não esteja publicado, isto pode significar que // o post foi despublicado ou ainda não foi publicado pela primeira vez if (!properties.published) { return post ? kind('remove', { id: post.id }) : kind('none') } const converted = convertExtractToPost({ properties, content, post }) // Se não existir evidência do post no banco, então precisa ser criado if (!post) return kind('upsert', converted) // Caso haja evidência, deve-se verificar se foi atualizado para // então realizar uma atualização return isPostUpdated(post, converted) ? kind('upsert', converted) : kind('none') }
Esta função representa exatamente o elo entre a parte bruta da integração e o modelo final da aplicação.
A função convertExtractToPost é a responsável por padronizar o dado vindo da API e aplicar regras específicas como cálculo de tempo de leitura:
export function convertExtractToPost({ properties, content, post, }: { properties: ExtractedPostProps content: ExtractedPostContent post?: Post }): Post { const p = { slug: properties.slug, title: properties.title, description: properties.description, tags: properties.tags, content: content.content, estimated_readtime: estimateReadingTime(content.content), external_ref: properties.id, published: true, publish_at: properties.publish_at, created_at: properties.created_at, updated_at: properties.updated_at, } return post ? { ...post, ...p } : Post.create(p) }
Benefícios diretos desse design
  • Código único para transformação → aplicado no ETL via webhook e no ETL periódico
  • Regras de negócio concentradas em um único ponto
  • Segurança de que o modelo local sempre representa a verdade do sistema
  • Evolução fácil: basta ajustar a transformação para mudanças futuras no conteúdo
  • Independência da estrutura externa do Notion
Essa função é o cérebro da sincronização: ela olha para os dados externos e para o estado interno e decide qual ação torna o sistema consistente.

Implementando MongoDB com Repository Pattern

Mesmo que o ETL funcione de forma desacoplada do banco escolhido, é importante mostrar como a interface Repository é concretizada. Neste caso, o backend utiliza o MongoDB como banco principal para armazenamento dos dados transformados.
Aqui é onde a arquitetura se prova consistente: o MongoDB apenas estende o contrato definido pelo Repository Pattern, mantendo o domínio totalmente independente de detalhes de infraestrutura.
Assim, trocar ou evoluir o banco no futuro deixa de ser um problema arquitetural — fica encapsulado dentro dessa implementação.
Veja abaixo a sua implementação:
export function MongoDBRepository<E extends Entity>({ database, collection, converter, projection, ...rest }: ClientConfig<E> | Config<E>): MongoDBRepository<E> { const client: MongoClient = (rest as any).client ?? new MongoClient((rest as any).uri) let coll: Collection<Document> let status = CONNECTION_STATUS.READY const connect: MongoDBRepository<E>['connect'] = async () => { if (status !== CONNECTION_STATUS.READY) throw new Error('Connection has been initialized') if (await isConnected(client)) { status = CONNECTION_STATUS.CONNECTED coll = client.db(database).collection(collection) return } const connection = await client.connect() coll = connection.db(database).collection(collection) status = CONNECTION_STATUS.CONNECTED } const disconnect = verifyConnectionProxy<MongoDBRepository<E>['disconnect']>( async () => { await client.close() status = CONNECTION_STATUS.DISCONNECTED }, ) const get = verifyConnectionProxy<Repository<E>['get']>(async id => { const item = await coll.findOne( { _id: ObjectId.createFromHexString(id) }, { projection }, ) if (item === null) return return converter.from(item) }) const set = verifyConnectionProxy<Repository<E>['set']>(async (entity: E) => { const { _id, ...props } = converter.to(entity) const result = await coll.updateOne( { _id: _id }, { $set: props }, { upsert: true }, ) return result.upsertedCount ? converter.from( await coll.findOne({ _id: result.upsertedId! }, { projection }), ) : entity }) const remove = verifyConnectionProxy<Repository<E>['remove']>( async ({ id }) => { await coll.deleteOne({ _id: ObjectId.createFromHexString(id) }) }, ) const query = verifyConnectionProxy<Repository<E>['query']>( async ({ where, sorts, cursor, limit } = {}) => { let find = coll.find(where ? whereAdaptToFindQuery(where) : {}) if (limit) { const skip = cursor ? parseInt(cursor) * limit : 0 find = find.limit(limit).skip(skip) } if (sorts) { find = find.sort(applySorts(sorts)) } if (projection) find.project(projection) return (await find.toArray()).map(converter.from) }, ) const batch = verifyConnectionProxy<Repository<E>['batch']>(async b => { const bulk = b.map(item => { if (item.type === 'remove') return { deleteOne: { filter: { _id: ObjectId.createFromHexString(item.data.id) }, }, } const { _id, ...props } = converter.to(item.data) return { updateOne: { filter: { _id }, update: { $set: props }, upsert: true, }, } }) const result = await coll.bulkWrite(bulk, { ordered: false }) return { status: result.isOk() ? 'successful' : 'failed', time: new Date(), } }) const clear = verifyConnectionProxy<MongoDBRepository<E>['clear']>( async () => { await coll.deleteMany({}) }, ) function verifyConnectionProxy<F extends (...args: any[]) => any>( fn: F, ): (...args: Parameters<F>) => ReturnType<F> { return (...args) => { if (status !== CONNECTION_STATUS.CONNECTED) throw new Error(' connection not established') return fn(...args) } } return applyTag('repository')({ get, set, remove, query, batch, connect, disconnect, clear, }) }
💡
Você pode conferir a implementação do MongoDB no repositório do github
O que essa implementação habilita no ETL?
  • Batch operations no Reconciler → atualizações em massa e alta performance
  • Converters para isolar o formato interno do formato do Mongo → domínio sem dependência do driver do banco
  • Validação de conexão antes das operações → proteção e segurança de execução
  • Queries flexíveis → base para futuras funcionalidades (paginadas, ordenadas, filtradas)

Resultados e benefícios da estratégia

Ao assumir o controle dos dados por meio do ETL e do repositório local, o backend passou a operar de forma mais confiável e eficiente. As melhorias podem ser percebidas tanto pela perspectiva do usuário final quanto pela visão do desenvolvedor que mantém a plataforma.
Aqui estão os principais ganhos obtidos:
  • Aumento expressivo na performance das requisições: O backend agora consome dados diretamente do banco local, eliminando chamadas desnecessárias com serviços externos.
  • Maior estabilidade e resiliência do sistema: Se a Notion API ficar lenta ou indisponível, o blog continua funcionando normalmente com os dados já sincronizados.
  • Governança e flexibilidade sobre o modelo de dados: O formato dos dados agora segue necessidades do blog, e não limitações da API externa. Permite inovações como: curtidas, categorização própria, histórico de edição, KPIs de leitura etc.
  • Menor custo de rede e processamento: A dependência de chamadas externas caiu drasticamente, reduzindo latência, riscos de timeout e uso excessivo de rede.
  • Melhor SEO e experiência do usuário: Com respostas mais rápidas do backend, mecanismos de busca reconhecem a melhoria — o que impacta diretamente visibilidade e ranking.
  • Arquitetura pronta para escalar: O pipeline ETL pode evoluir e absorver outras fontes no futuro sem reescrever a base da aplicação.
Em resumo, o blog deixa de reagir ao que sistemas externos impõem, e passa a se comportar como uma plataforma que possui autonomia técnica e decisões de negócio próprias.

Riscos e cuidados

Nenhuma arquitetura vem sem trocas. Ao trazer os dados para dentro do sistema, certos cuidados passam a ser responsabilidade direta do sistema.
Aqui estão os principais pontos de atenção:
  • Risco de desatualização dos dados locais: Se o ETL falhar ou ficar inoperante, o banco pode armazenar versões obsoletas dos conteúdos. Para resolver é necessário monitorar a integridade e validar com o Reconciler.
  • Dependência da consistência entre múltiplas fontes: Agora existe uma duplicação de dados — e é preciso garantir alinhamento constante entre elas. Para isto, Testes automazidos, logs detalhados e alertas ajudam muito.
  • Tratamento de mudanças no schema externo: APIs externas podem introduzir novos campos ou remover propriedades utilizadas no pipeline. O código deve ser resiliente a variações e falhas parciais.
  • Volume de dados pode crescer com o tempo: Mesmo com conteúdo estático, versões acumulam histórico. Para resolver é necessário planejar indexação, arquivamento e limpeza periódica.
  • Erros de transformação podem propagar dados inválidos: Uma conversão mal feita não só atrapalha funcionalidades, como pode quebrar a UI. Validação rigorosa na fase de transformação é fundamental.
Em outras palavras: ao ganhar autonomia, também assumimos responsabilidade total pela qualidade dos dados.
Mas esse trade-off é justamente o que permite que o sistema cresça de forma saudável, evoluindo o produto com liberdade e segurança.

Conclusão

Ao longo deste artigo, apresentei a evolução da arquitetura do meu blog a partir de uma necessidade real: garantir autonomia, escalabilidade e performance de uma plataforma que nasceu simples, mas que tem grande potencial de crescimento.
A criação do pipeline de ETL, somada ao repositório local de dados, não apenas resolveu os gargalos iniciais — como a dependência total da Notion API e a latência do backend — mas também abriu espaço para que novas funcionalidades possam existir sem limitações impostas por sistemas externos.
Agora é seguir refinando, aprendendo e evoluindo esse ecossistema é para tornar esse projeto uma plataforma viva — e que continua cumprindo o propósito inicial: compartilhar conhecimento com qualidade, velocidade e experiência.