Stored procedure: como dividir a lógica em componentes

Uma stored procedure que faz tudo é o equivalente, no banco de dados, ao método gigante que todo desenvolvedor de aplicações já enfrentou. Ela começa pequena, cresce com o tempo e chega a um ponto em que ninguém consegue ler, testar ou alterar aquele código com segurança.

A engenharia de software conhece a resposta para esse problema há décadas. Decomposição, encapsulamento e dependências explícitas existem justamente para conter esse tipo de crescimento desordenado.

No entanto, raramente alguém aplica esses mesmos princípios de forma consciente quando a lógica vive dentro do banco. O T-SQL não oferece classes, herança, interfaces ou polimorfismo nos moldes do C#, e muita gente conclui que boas práticas de projeto deixam de valer ali.

Essa conclusão está errada. A linguagem muda e as ferramentas mudam, mas os princípios permanecem.

Este artigo parte da publicação SQL Decomposition in a Nutshell, de Jerry Nixon, Principal Program Manager do time de Azure SQL, no blog Azure SQL Dev Corner. Além disso, desenvolve o tema com apoio da documentação oficial do SQL Server no Microsoft Learn.

O problema da stored procedure que sabe demais

Lógica complexa no banco fica difícil de compreender quando responsabilidades demais se acumulam em um único objeto.

Normalmente, uma stored procedure nasce como uma consulta direta e inofensiva. Com o tempo, porém, ela passa a validar entradas, executar buscas, ordenar resultados, transformar dados, tratar erros e orquestrar outras operações.

Em algum momento essa stored procedure simplesmente entende demais sobre o sistema inteiro. Assim, qualquer alteração exige compreender todo o objeto, e qualquer falha exige investigar todo o objeto.

O custo não aparece de imediato. Ele aparece no primeiro incidente em produção, quando alguém precisa descobrir em qual das setecentas linhas o comportamento deixou de corresponder à expectativa.

Um exemplo concreto com busca híbrida

Busca híbrida combina mais de uma técnica de recuperação de informação, normalmente busca full text e busca vetorial, para melhorar a qualidade dos resultados.

Em vez de confiar em uma única estratégia de ranqueamento, o sistema recupera candidatos por caminhos diferentes. Em seguida, funde esses conjuntos em uma classificação final.

A documentação da Microsoft descreve o padrão como uma execução paralela de consultas textuais e vetoriais. Depois, o sistema combina os resultados por Reciprocal Rank Fusion.

A vantagem da busca vetorial é encontrar conteúdo conceitualmente próximo da consulta, mesmo sem correspondência de palavras. Por outro lado, a busca textual se destaca pela precisão em códigos de produto, jargões, datas e nomes próprios.

Uma solução completa envolve reescrita da consulta, geração de embeddings, busca full text, busca vetorial, fusão, reranqueamento e geração de resposta.

Cada etapa contribui para o resultado final, mas cada uma também é naturalmente separável. Por isso, colocar tudo em uma só stored procedure seria a forma mais rápida de criar o objeto que sabe demais.

A busca full text deve funcionar sem a busca vetorial. Da mesma forma, a busca vetorial deve ser testável sem a fusão, e a fusão deve operar sobre resultados sem saber como alguém os produziu.

Se você quer conhecer os recursos de vetores do SQL Server 2025 antes de seguir, vale ler o artigo sobre REST APIs, vetores e inteligência artificial no SQL Server 2025.

Decomposição cria fronteiras

Introduzir componentes separados adiciona alguma sofisticação estrutural ao sistema. Ao mesmo tempo, reduz a complexidade que cada ponto exige compreender.

Isso parece contraditório, mas não é. A arquitetura ganha peças, e cada peça perde complexidade.

Em vez de pedir a uma única stored procedure que compreenda toda a arquitetura, você divide o trabalho em unidades menores com responsabilidades mais estreitas.

Por exemplo, uma procedure chamada ProductSearch_FullText cuida apenas da recuperação textual. Outra cuida da busca vetorial, e uma terceira cuida da fusão.

EXEC dbo.ProductSearch_FullText
    @Query = @Query,
    @TopN  = @TopN;

EXEC dbo.ProductSearch_Vector
    @QueryVector = @QueryVector,
    @TopN        = @TopN;

O resultado não é menos capacidade. É menos complexidade por componente.

Unidades menores são mais fáceis de ler, testar, alterar e diagnosticar. Dessa forma, a decomposição simplifica o desenho sem simplificar aquilo que o sistema consegue fazer.

Quem conhece o princípio da responsabilidade única do SOLID vai reconhecer a ideia. Cada stored procedure passa a ter um único motivo para mudar.

Encapsulamento em cada stored procedure

A decomposição cria as fronteiras. Já o encapsulamento é o que faz essas fronteiras valerem alguma coisa.

Ao chamar um método em uma aplicação, o desenvolvedor se importa com as entradas, com a saída e com o comportamento esperado. Ele não precisa entender cada linha da implementação.

O mesmo princípio vale dentro do banco. Quem chama ProductSearch_FullText não deveria precisar conhecer cada detalhe de FREETEXTTABLE, cada regra de validação ou cada decisão de ranqueamento.

O que interessa, portanto, é o contrato.

CREATE PROCEDURE dbo.ProductSearch_FullText
    @Query nvarchar(4000),
    @TopN  int = 20
AS
BEGIN
    -- Retorna apenas o identificador e a posição no ranking
    SELECT TOP (@TopN)
        ft.[KEY] AS ProductId,
        CAST(ROW_NUMBER() OVER (ORDER BY ft.[RANK] DESC) AS int) AS Rank
    FROM FREETEXTTABLE(dbo.Product, *, @Query) AS ft
    ORDER BY ft.[RANK] DESC;
END;

Uma consulta entra e produtos ranqueados saem. Como a stored procedure encontrou esses produtos permanece atrás da fronteira.

Essa separação melhora a manutenibilidade porque mudanças internas não obrigam mudanças externas. Assim, você pode trocar a implementação da busca textual, acrescentar validações ou reescrever a consulta sem tocar em quem orquestra o processo.

Ausência de estado torna as dependências visíveis

Componentes sem estado oculto são mais fáceis de isolar. Aqui, ausência de estado não significa ignorar o estado do banco, já que ler dados é exatamente o propósito do sistema.

Significa evitar estado implícito de execução. Tabelas temporárias globais, contexto de sessão e valores que uma chamada anterior deixou para trás são exemplos desse acoplamento invisível.

Suposições sobre o que outro componente já fez também entram nessa lista. Por isso, o estado relevante deve atravessar a fronteira de forma explícita, como parâmetro.

CREATE PROCEDURE dbo.ProductSearch_Vector
    @QueryVector vector(1536),
    @TopN        int = 20
AS
BEGIN
    -- Distância exata entre o vetor da consulta e cada embedding
    SELECT TOP (@TopN)
        ProductId,
        CAST(ROW_NUMBER() OVER (ORDER BY d.Distance) AS int) AS Rank
    FROM dbo.ProductEmbedding
    CROSS APPLY (
        VALUES (VECTOR_DISTANCE('cosine', Embedding, @QueryVector))
    ) AS d(Distance)
    ORDER BY d.Distance;
END;

Tudo o que é específico desta busca aparece na assinatura da stored procedure: o vetor de consulta e a quantidade de candidatos desejada.

O paralelo com injeção de dependência é direto. Em vez de o componente buscar por conta própria tudo aquilo de que precisa, quem o chama fornece as dependências.

O benefício não é a ausência de estado como virtude isolada. Na verdade, é a redução de contexto oculto, que torna execuções reproduzíveis, falhas investigáveis e componentes reutilizáveis.

Vale observar que a geração do embedding não fica escondida dentro da procedure. Ela pode ser outro componente do banco ou pode acontecer na aplicação, porque o contrato de entrada é simples: forneça um vetor.

O que o desenvolvedor SQL tem em mãos

O desenvolvedor de banco não dispõe dos mesmos blocos de construção do desenvolvedor de aplicações. Mesmo assim, está longe de não ter ferramentas arquiteturais.

Cada stored procedure oferece uma fronteira executável. Tipos de tabela definidos pelo usuário oferecem formatos de dados reutilizáveis, e funções oferecem lógica reutilizável.

Schemas oferecem namespaces e limites de segurança. Por fim, THROW permite definir falhas intencionais.

São ferramentas diferentes, servindo aos mesmos princípios de projeto.

Tipos de tabela entre uma stored procedure e outra

Quando você decompõe o pipeline, cada componente precisa de uma forma previsível de trocar dados com o próximo.

Um tipo de tabela definido pelo usuário dá a esses dados um formato nomeado e reutilizável. Para quem vem do C#, a analogia mais próxima é a de um pequeno DTO tabular.

CREATE TYPE dbo.ProductSearchResult AS TABLE
(
    ProductId int NOT NULL,
    Rank      int NOT NULL
);

Com o tipo definido, diferentes partes do pipeline passam a operar sobre o mesmo formato conhecido.

DECLARE @FullText dbo.ProductSearchResult;
DECLARE @Vector   dbo.ProductSearchResult;

-- Captura o resultado de cada stored procedure em uma variável de tabela
INSERT INTO @FullText
EXEC dbo.ProductSearch_FullText @Query, @TopN;

INSERT INTO @Vector
EXEC dbo.ProductSearch_Vector @QueryVector, @TopN;

Há um benefício sutil nessa padronização. O FREETEXTTABLE devolve uma pontuação própria de relevância, enquanto a busca vetorial devolve uma distância.

Esses dois valores significam coisas diferentes e não admitem comparação direta. Como a fusão por Reciprocal Rank Fusion depende da posição e não da pontuação bruta, ambos os recuperadores expõem apenas um ranque de 1 a N.

Uma distinção importante merece registro. O tipo de tabela não tipa formalmente o result set de uma stored procedure, então cada procedure continua responsável por devolver colunas compatíveis.

O que o tipo oferece é um contrato real para variáveis de tabela e parâmetros com valor de tabela. Segundo a documentação de parâmetros com valor de tabela, esses parâmetros entram nas rotinas obrigatoriamente como READONLY.

Além disso, o comando ALTER TABLE não altera a estrutura de um tipo de tabela. Por isso, convém tratar esses tipos como contratos relativamente estáveis.

Outro ponto documentado é que o SQL Server não mantém estatísticas sobre colunas de parâmetros com valor de tabela.

Funções definidas pelo usuário

Conforme o pipeline se divide, alguma lógica naturalmente aparece em mais de um componente.

Uma função permite dar nome a essa lógica e reutilizá-la em vez de repetir a mesma expressão pelo sistema. O cálculo de Reciprocal Rank Fusion é um exemplo típico, já que transforma repetidamente um ranque em uma pontuação.

CREATE FUNCTION dbo.ProductSearch_RrfScore (@Rank int)
RETURNS TABLE AS
RETURN
(
    -- 60 é a constante usual do Reciprocal Rank Fusion
    SELECT 1.0 / (60 + @Rank) AS Score
);

Qualquer componente passa a reutilizar o cálculo pela mesma porta.

SELECT r.ProductId, s.Score
FROM @FullText AS r
CROSS APPLY dbo.ProductSearch_RrfScore(r.Rank) AS s;

O objetivo não é mover toda expressão para dentro de uma função. Use função quando a lógica tem significado próprio, quando há reuso real ou quando a complexidade justifica uma fronteira.

Funções em linha com valor de tabela são especialmente úteis para lógica baseada em conjuntos. Afinal, o mecanismo consegue incorporá-las ao plano da consulta que as chama.

Schemas como namespace e como segurança

Desenvolvedores de aplicações usam namespaces para organizar código relacionado. Schemas no SQL Server cumprem papel organizacional semelhante, com uma vantagem adicional.

Eles também funcionam como fronteiras de segurança. Por exemplo, uma arquitetura de busca pode expor seus objetos sob um schema search, concedendo permissão sobre as capacidades sem conceder acesso direto às tabelas subjacentes.

Em outras palavras, isso é encapsulamento em outra camada: expor o necessário e manter os detalhes de implementação atrás da fronteira.

Erros também fazem parte do contrato da stored procedure

O contrato de um componente inclui as suas falhas.

-- Falha intencional com número e mensagem próprios
IF @TopN < 1
    THROW 51001, 'TopN must be greater than zero.', 1;

Números e mensagens de erro personalizados permitem que a stored procedure falhe de forma intencional. Assim, ela evita vazar um erro obscuro vindo de alguma camada profunda da implementação.

A documentação do THROW exige que o número do erro seja maior ou igual a 50000. Também define a severidade como 16 sempre que o comando dispara uma nova exceção.

Para quem chama, a ideia é familiar. É preciso saber não apenas como é o sucesso, mas também quais falhas se espera tratar.

Compondo cada stored procedure no pipeline

Cada componente até aqui é útil isoladamente. O passo seguinte, então, é compô-los.

A fusão recebe os dois conjuntos de resultados sem saber como cada um deles surgiu.

CREATE PROCEDURE dbo.ProductSearch_Fuse
    @FullText dbo.ProductSearchResult READONLY,
    @Vector   dbo.ProductSearchResult READONLY,
    @TopN     int = 20
AS
BEGIN
    WITH Scores AS (
        SELECT r.ProductId, s.Score
        FROM @FullText AS r
        CROSS APPLY dbo.ProductSearch_RrfScore(r.Rank) AS s
        UNION ALL
        SELECT r.ProductId, s.Score
        FROM @Vector AS r
        CROSS APPLY dbo.ProductSearch_RrfScore(r.Rank) AS s
    ),
    Fused AS (
        -- Soma as contribuições de cada recuperador
        SELECT ProductId, SUM(Score) AS FusionScore
        FROM Scores
        GROUP BY ProductId
    )
    SELECT TOP (@TopN) ProductId, FusionScore
    FROM Fused
    ORDER BY FusionScore DESC;
END;

O uso de UNION ALL é deliberado. Um produto que aparece em apenas um dos recuperadores continua participando da fusão, e um produto presente nos dois soma as duas contribuições.

Acima disso fica uma stored procedure de orquestração deliberadamente fina, cuja única responsabilidade é descrever o fluxo.

CREATE PROCEDURE dbo.ProductSearch
    @Query       nvarchar(4000),
    @QueryVector vector(1536),
    @TopN        int = 20
AS
BEGIN
    DECLARE @FullText dbo.ProductSearchResult;
    DECLARE @Vector   dbo.ProductSearchResult;

    INSERT INTO @FullText
    EXEC dbo.ProductSearch_FullText @Query, @TopN;

    INSERT INTO @Vector
    EXEC dbo.ProductSearch_Vector @QueryVector, @TopN;

    EXEC dbo.ProductSearch_Fuse
        @FullText = @FullText,
        @Vector   = @Vector,
        @TopN     = @TopN;
END;

É neste ponto que o valor da decomposição se torna visível. O orquestrador orquestra, a busca textual faz busca textual, a busca vetorial faz busca vetorial e a fusão funde.

Com isso, cada componente evolui sem exigir que os demais entendam o que mudou.

O SQL cobra os seus próprios custos

Aqui os padrões de aplicação e o desenvolvimento de banco se separam um pouco.

Chamar uma stored procedure não equivale a chamar um método em C#. Afinal, fronteiras no banco têm custos e limitações no nível do mecanismo.

O INSERT ... EXEC é o exemplo mais imediato, porque não admite aninhamento. Se ProductSearch_FullText também usasse INSERT ... EXEC internamente, o orquestrador não conseguiria capturar o resultado da mesma forma.

A documentação do INSERT registra ainda que um parâmetro com valor de tabela não pode ser alvo de um INSERT ... EXEC. Contudo, ele pode aparecer como origem dentro da procedure executada.

Uma alternativa para pipelines mais compostos é implementar as operações folha como funções em linha com valor de tabela em vez de procedures.

Há também o tema das estatísticas. Variáveis de tabela e parâmetros com valor de tabela não fornecem as mesmas estatísticas de coluna que tabelas temporárias oferecem.

Para conjuntos pequenos de candidatos, comuns em fusão de busca, isso costuma ser irrelevante. Já para conjuntos intermediários grandes, uma tabela temporária pode produzir planos de execução melhores.

O exemplo vetorial merece a mesma leitura crítica. O VECTOR_DISTANCE calcula distância exata e nunca utiliza um índice vetorial, mesmo que exista um disponível.

A documentação recomenda a busca exata quando o volume é pequeno, tipicamente abaixo de cinquenta mil vetores após a aplicação dos predicados. Acima disso, VECTOR_SEARCH com índice vetorial tende a ser a escolha de produção quando a busca aproximada é aceitável.

E é exatamente por isso que a fronteira importa. Trocar a implementação da stored procedure de recuperação vetorial não exige redesenhar a fusão nem o restante do pipeline.

Nada disso é argumento contra a decomposição. Na verdade, são lembretes de que bom desenho de software ainda precisa respeitar o mecanismo de banco de dados.

Testando cada stored procedure isoladamente

O retorno do investimento aparece com clareza quando algo dá errado.

Com componentes isolados, você executa a busca textual sozinha, sem geração de embedding, sem busca vetorial, sem fusão e sem geração de resposta.

-- Executa apenas a recuperação textual
EXEC dbo.ProductSearch_FullText
    @Query = N'running shoes',
    @TopN  = 10;

Quando um resultado híbrido parece errado, a investigação vira um procedimento. Primeiro, inspecione os resultados textuais e, em seguida, os resultados vetoriais.

Depois, teste a fusão isoladamente com variáveis de tabela preenchidas à mão. Por fim, identifique a fronteira em que o comportamento deixou de corresponder à expectativa.

Isso é incomparavelmente mais simples do que raciocinar sobre uma única stored procedure gigante.

Se você quer praticar consultas que ajudam nesse tipo de diagnóstico, o artigo com 10 recursos de consultas SQL avançadas traz exemplos úteis.

Não decomponha tudo

A decomposição tem custo. Mais componentes significam mais objetos, mais contratos e mais arquitetura para compreender.

Uma consulta de três linhas não precisa de três procedures, um tipo de tabela e uma função. Afinal, o objetivo nunca foi produzir mais componentes.

O objetivo é produzir fronteiras significativas. Decomponha onde as responsabilidades são de fato independentes, onde a lógica merece reuso ou onde o teste se beneficia do isolamento.

Também vale decompor quando uma stored procedure precisa evoluir sem arrastar o resto do sistema. Fora desses casos, mantenha simples aquilo que já é simples.

Conclusão

Bom desenho de banco de dados não consiste em fazer o SQL se parecer com C#. Consiste, na verdade, em aplicar os mesmos princípios de engenharia comprovados onde eles fazem sentido.

A decomposição dá fronteiras significativas à lógica complexa, e o encapsulamento mantém os detalhes de implementação atrás dessas fronteiras. Já a ausência de estado oculto torna as dependências explícitas.

O SQL Server entrega cada stored procedure, tipos, funções, schemas e erros intencionais para colocar essas ideias em prática.

Software de qualidade é mais fácil de ler, testar, manter e alterar com segurança. Isso vale tanto para a aplicação quanto para a stored procedure que roda dentro do banco de dados.

FAQ

Decompor uma stored procedure não aumenta a quantidade de objetos sem necessidade?

Aumenta a quantidade de objetos e reduz a complexidade de cada um. O critério é a existência de responsabilidades independentes, reuso real ou benefício claro em teste e evolução isolada.

Um tipo de tabela definido pelo usuário garante o formato do retorno de uma procedure?

Não. O tipo dá contrato a variáveis de tabela e a parâmetros com valor de tabela, mas a procedure continua responsável por devolver colunas compatíveis com o destino do INSERT ... EXEC.

Por que não usar tabelas temporárias no lugar de variáveis de tabela?

Ambas as opções são válidas e a escolha depende do volume. Variáveis de tabela e parâmetros com valor de tabela não oferecem estatísticas de coluna, o que pesa apenas em conjuntos intermediários grandes.

O VECTOR_DISTANCE executa cálculo exato de distância e não utiliza índice vetorial. O VECTOR_SEARCH utiliza índice vetorial para busca aproximada por vizinhos mais próximos, algo necessário em volumes maiores.

Por que os recuperadores expõem ranque em vez da pontuação original?

Porque a pontuação de relevância do full text e a distância vetorial medem grandezas distintas. A fusão por Reciprocal Rank Fusion depende da posição do item, não do valor bruto.

A limitação do INSERT ... EXEC inviabiliza pipelines mais profundos?

Não inviabiliza, mas impõe um cuidado de projeto. Como o INSERT ... EXEC não admite aninhamento, operações folha de pipelines mais compostos funcionam melhor como funções em linha com valor de tabela.

Schemas servem apenas para organização?

Não. Eles organizam objetos como namespaces e também funcionam como fronteira de segurança, permitindo conceder acesso às capacidades expostas sem acesso direto às tabelas.

Glossário

Termo Significado
Decomposição Divisão de lógica complexa em unidades menores com responsabilidades estreitas e bem definidas
Encapsulamento Ocultação dos detalhes de implementação atrás de um contrato estável de entrada e saída
Ausência de estado Ausência de estado oculto de execução, com as dependências atravessando a fronteira como parâmetros
Stored procedure Rotina T-SQL armazenada no banco, que funciona como fronteira executável de um componente
Tipo de tabela definido pelo usuário Tipo criado com CREATE TYPE ... AS TABLE que define um formato tabular nomeado e reutilizável
Parâmetro com valor de tabela Parâmetro baseado em um tipo de tabela, obrigatoriamente READONLY dentro da rotina
Função em linha com valor de tabela Função definida por um único SELECT, que o mecanismo pode incorporar ao plano da consulta chamadora
Schema Agrupamento nomeado de objetos que atua como namespace e como fronteira de segurança
THROW Comando que gera erro intencional, com número e mensagem próprios, como parte do contrato do componente
Busca híbrida Combinação de busca full text e busca vetorial, com fusão dos resultados em uma classificação única
FREETEXTTABLE Função de busca full text que retorna chaves e uma pontuação de relevância para os itens encontrados
Embedding Representação numérica vetorial gerada a partir de um texto ou outro conteúdo
VECTOR_DISTANCE Função T-SQL que calcula distância exata entre dois vetores, sem uso de índice vetorial
VECTOR_SEARCH Função T-SQL que executa busca aproximada por vizinhos mais próximos utilizando índice vetorial
Reciprocal Rank Fusion Algoritmo que combina múltiplas listas ranqueadas em uma única, usando o inverso da posição de cada item
INSERT ... EXEC Construção que captura o result set de uma procedure em uma tabela ou variável de tabela, sem permitir aninhamento
Injeção de dependência Padrão em que quem chama fornece as dependências de um componente, em vez de o componente buscá-las

Fontes