Criando, organizando e reutilizando expressões regulares em C#

Expressões regulares em C# são ferramentas poderosas para validar, extrair e transformar texto, mas viram um problema quando ficam espalhadas pelo código. Neste artigo, você vai ver uma abordagem estruturada para criar, organizar e reutilizar esses padrões com foco em desempenho, testabilidade e clareza de intenção.

Sem organização, o mesmo padrão aparece duplicado em vários arquivos, nasce sem cache e fica difícil de testar. Por isso, a proposta aqui combina o atributo [GeneratedRegex], uma classe central, métodos de extensão e testes de unidade isolados.

O problema das expressões regulares em C# dispersas

A forma mais comum de usar expressões regulares em C# é instanciar o padrão diretamente no ponto de uso. O exemplo abaixo mostra esse estilo em um método de extensão simples.

public static string Normalize(this string value)
    => Regex.Replace(value.Trim(), @"\s{2,}", " ");

Essa abordagem parece inofensiva. No entanto, ela traz três problemas sérios que crescem junto com o projeto.

Custo de inicialização

O primeiro problema é o custo de inicialização. Cada chamada a new Regex(pattern) ou a Regex.Replace(input, pattern, replacement) sem RegexOptions.Compiled faz o motor interpretar o padrão em tempo de execução.

Os métodos estáticos usam um cache interno que, por padrão, guarda apenas 15 padrões. Além disso, mesmo com a flag Compiled, o custo de compilação continua existindo e aparece na primeira execução.

Duplicação e falta de testes

O segundo problema é a duplicação. Sem um ponto central, a equipe reescreve o mesmo padrão em lugares diferentes, muitas vezes com pequenas variações que introduzem bugs sutis.

O terceiro problema é a ausência de testabilidade. Uma expressão embutida em um método de negócio não permite um teste isolado, então ninguém sabe ao certo o que ela aceita e o que ela rejeita.

A solução com GeneratedRegex

A partir do .NET 7, o C# ganhou o atributo [GeneratedRegex]. Ele instrui um source generator a escrever, em tempo de compilação, todo o código da lógica de correspondência do padrão.

Na prática, o resultado não tem custo de parsing em runtime, funciona com Native AOT e Blazor WebAssembly e pode viver em uma única classe estática parcial. Segundo a documentação oficial dos source generators de Regex, você recebe os ganhos de throughput do modo Compiled e ainda inicia a aplicação mais rápido.

O exemplo a seguir reúne os padrões de um projeto real em uma classe chamada RegularExpression.

using System.Text.RegularExpressions;

namespace Explanna.Core.RegularExpressions;

public static partial class RegularExpression
{
    /// <summary>
    /// Remove tabulações e quebras de linha
    /// </summary>
    [GeneratedRegex(@"[\t\r\n]")]
    public static partial Regex TabsAndLineBreaksRegex();

    /// <summary>
    /// Colapsa múltiplos espaços consecutivos em um único espaço
    /// </summary>
    [GeneratedRegex(@" {2,}")]
    public static partial Regex MultipleSpacesRegex();

    /// <summary>
    /// Remove qualquer caractere que não seja letra minúscula, dígito ou espaço
    /// </summary>
    [GeneratedRegex(@"[^a-z0-9\s]")]
    public static partial Regex NonSlugCharactersRegex();

    /// <summary>
    /// Substitui sequências de espaço em branco por um hífen
    /// </summary>
    [GeneratedRegex(@"\s+")]
    public static partial Regex WhiteSpaceRegex();

    /// <summary>
    /// Valida que um nome possui pelo menos duas palavras, cada uma com no mínimo 2 caracteres,
    /// permitindo letras Unicode e hífens
    /// </summary>
    [GeneratedRegex(@"^(?:\p{L}[\p{L}-]+)(?:\s+(?:\p{L}[\p{L}-]+))+$")]
    public static partial Regex NameRegex();

    /// <summary>
    /// Valida um código de convite: exatamente 8 caracteres alfanuméricos maiúsculos
    /// </summary>
    [GeneratedRegex(@"^[A-Z0-9]{8}$")]
    public static partial Regex InvitationCodeRegex();

    /// <summary>
    /// Remove qualquer caractere que não seja letra Unicode, hífen ou espaço
    /// </summary>
    [GeneratedRegex(@"[^\p{L}\- ]")]
    public static partial Regex NonNameCharactersRegex();

    /// <summary>
    /// Extrai o domínio de uma URL
    /// </summary>
    [GeneratedRegex(@"^(?:https?://)?(?:www\.)?([a-z0-9][a-z0-9\-]{0,61}[a-z0-9]?(?:\.[a-z]{2,})+)")]
    public static partial Regex DomainExtractorRegex();

    /// <summary>
    /// Extrai uma URL de um texto
    /// </summary>
    [GeneratedRegex(@"https?://(?:www\.)?[-a-z0-9@:%._+~#=]{1,256}\.[a-z]{2,}\b(?:[-a-z0-9@:%_+.~#?&/=]*)")]
    public static partial Regex UrlRegex();
}

Requisitos do source generator

Alguns requisitos técnicos garantem que o gerador funcione. Primeiro, a classe precisa ser static e partial, porque o compilador completa a implementação em outro arquivo.

Em seguida, cada método precisa ser partial, sem parâmetros, não genérico e com retorno Regex. Quando alguma dessas regras falha, o compilador emite o diagnóstico SYSLIB1043 e aponta exatamente o problema.

Também vale lembrar que o gerador ignora a flag RegexOptions.Compiled. Afinal, a compilação já acontece no build, então essa opção se torna redundante.

Por fim, a implementação gerada já guarda uma instância única de Regex em cache. Dessa forma, você pode chamar TabsAndLineBreaksRegex() várias vezes sem criar objetos novos.

Por que centralizar expressões regulares em C# em uma classe

Uma decisão de design importante é manter todas as expressões regulares em C# do projeto em uma única classe, em vez de criar uma classe por contexto. Há duas razões principais para essa escolha.

A primeira é a visibilidade global. Quando os padrões ficam juntos, qualquer pessoa que precise de uma Regex consulta essa classe antes de criar outra, o que reduz bastante a duplicação.

A segunda é a coesão do conjunto. Expressões regulares são primitivas de infraestrutura, não conceitos de domínio, assim como os métodos de extensão de string vivem em StringExtension.

Em outras palavras, a organização por responsabilidade técnica funciona melhor do que a organização por contexto de uso para esse tipo de artefato. Além disso, o analisador SYSLIB1045 sugere a conversão de chamadas antigas para [GeneratedRegex], o que facilita migrar padrões dispersos para a classe central.

Separação de responsabilidades: Regex, extensões e Value Objects

Uma armadilha comum é misturar a definição do padrão com o comportamento que o utiliza. Por isso, a abordagem divide o código em três camadas distintas, cada uma com um papel claro.

A primeira camada é a definição, na classe RegularExpression. Ela não sabe nada sobre como alguém vai usar cada padrão.

A segunda camada é o comportamento, implementado como métodos de extensão em StringExtension. Esses métodos combinam uma ou mais expressões para produzir um resultado útil ao negócio.

A terceira camada é o Value Object, que usa os métodos de extensão para normalizar e validar o valor no momento da criação. Se quiser aprofundar esse conceito, veja como criar bons Value Objects em C#.

// Camada 1: definição
public static partial class RegularExpression
{
    [GeneratedRegex(@"[\t\r\n]")]
    public static partial Regex TabsAndLineBreaksRegex();

    [GeneratedRegex(@" {2,}")]
    public static partial Regex MultipleSpacesRegex();
}

// Camada 2: comportamento
public static class StringExtension
{
    private static readonly Regex TabsAndLineBreaks = RegularExpression.TabsAndLineBreaksRegex();
    private static readonly Regex MultipleSpaces = RegularExpression.MultipleSpacesRegex();

    public static string NormalizeTitle(this string value)
    {
        var result = value.Trim();
        result = TabsAndLineBreaks.Replace(result, string.Empty);
        result = MultipleSpaces.Replace(result, " ");
        return result;
    }
}

// Camada 3: Value Object
public sealed record Title
{
    private const int MinLength = 3;
    private const int MaxLength = 120;

    public string Value { get; }

    private Title(string value) => Value = value;

    public static Title Create(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new NullOrEmptyException(nameof(value));

        var normalized = value.NormalizeTitle();

        return normalized.Length switch
        {
            < MinLength or > MaxLength => throw new InvalidLengthException(MinLength, MaxLength),
            _ => new Title(normalized)
        };
    }
}

Com essa divisão, cada camada aceita testes completamente independentes das demais. Assim, uma falha no Value Object não esconde um defeito no padrão, e vice-versa.

Os métodos de extensão merecem atenção especial nesse desenho. O artigo sobre extension methods em C# mostra como criar, segmentar e testar esse tipo de método.

Composição de expressões regulares em C#

Alguns comportamentos exigem aplicar várias expressões regulares em C# em sequência. Nesse caso, a ordem de aplicação determina se o resultado sai correto.

Gerando slugs

Um exemplo concreto é a geração de slugs. Para transformar "Clean Architecture in .NET" em "clean-architecture-in-net", o método aplica uma sequência de operações, cada uma com sua própria expressão.

public static string Slugify(this string value)
{
    // 1. Normaliza espaços e remove tabs/quebras de linha
    var normalized = value.NormalizeTitle().ToLowerInvariant();

    // 2. Remove caracteres que não são letras, dígitos ou espaços
    var noSpecial = RegularExpression.NonSlugCharactersRegex().Replace(normalized, string.Empty);

    // 3. Substitui espaços por hífens e remove hífens nas bordas
    return RegularExpression.WhiteSpaceRegex().Replace(noSpecial, "-").Trim('-');
}

A ordem importa. Se o método removesse os caracteres especiais antes da normalização, um ponto seguido de espaço poderia gerar um hífen duplo.

Da mesma forma, se o lowercase viesse depois do filtro [^a-z0-9\s], o filtro descartaria as letras maiúsculas antes da conversão. Portanto, o título perderia letras válidas sem nenhum aviso.

Extraindo domínios

Outro exemplo é a extração de domínios. Para aceitar URLs em qualquer capitalização, o método aplica ToLowerInvariant() antes do match.

public static string? ExtractDomain(this string value)
{
    var match = RegularExpression.DomainExtractorRegex().Match(value.ToLowerInvariant());
    return match.Success ? match.Groups[1].Value : null;
}

Documentar a ordem e o motivo de cada etapa nos comentários do método economiza horas de depuração no futuro. Além disso, essa documentação ajuda quem chega ao projeto a entender por que a sequência não pode mudar.

Nomenclatura das expressões regulares em C#

O nome de cada uma das expressões regulares em C# deve comunicar o que ele faz, não apenas o que ele encontra. Em geral, dois estilos de nomenclatura aparecem nos projetos.

O primeiro estilo descreve a ação, como TabsAndLineBreaksRegex, NonSlugCharactersRegex e MultipleSpacesRegex. Ele funciona bem para padrões usados em substituições, porque o nome diz o que será removido ou transformado.

O segundo estilo descreve a validação, como NameRegex, InvitationCodeRegex e DomainExtractorRegex. Nesse caso, o nome indica o conceito que o padrão valida ou extrai.

Por outro lado, nomes genéricos como MyRegex, Pattern ou Regex1 precisam sair do código. Afinal, um padrão sem nome descritivo parece igual a qualquer outro à primeira vista.

Cuidados com Unicode nas expressões regulares em C#

Uma das decisões mais importantes ao escrever expressões regulares em C# para validar texto é a escolha entre classes de caracteres ASCII e classes Unicode. Essa escolha define quem o sistema consegue atender.

O padrão [a-zA-Z] corresponde apenas ao alfabeto latino básico. Assim, um sistema que precise aceitar nomes como João, Renée, Ângela ou Müller rejeitaria entradas válidas com esse padrão.

A alternativa é usar \p{L}, a categoria Unicode que corresponde a qualquer letra, de qualquer idioma ou alfabeto. O exemplo abaixo compara as duas opções.

// Rejeita: João, Renée, Müller
[GeneratedRegex(@"^[a-zA-Z ]+$")]
public static partial Regex AsciiNameRegex();

// Aceita: João, Renée, Müller, 山田, Αλέξανδρος
[GeneratedRegex(@"^[\p{L} ]+$")]
public static partial Regex UnicodeNameRegex();

A decisão deve ser consciente e documentada. Para slugs, que precisam funcionar em URLs, [a-z0-9] é a escolha correta.

Já para nomes de pessoas, \p{L} se torna obrigatório em qualquer sistema que pretenda atender usuários fora do contexto anglófono. Quem quiser revisar como o .NET trata texto pode consultar o guia de manipulação de strings em C#.

Âncoras e correspondência parcial

Um erro frequente é esquecer as âncoras ^ e $, que fixam o padrão no início e no fim da string. Sem elas, uma validação pode encontrar uma substring válida dentro de uma string inválida.

// Sem âncoras: "ABCD1234!@#" passa, pois "ABCD1234" corresponde
[GeneratedRegex(@"[A-Z0-9]{8}")]
public static partial Regex WithoutAnchorsRegex();

// Com âncoras: "ABCD1234!@#" falha, pois a string inteira é avaliada
[GeneratedRegex(@"^[A-Z0-9]{8}$")]
public static partial Regex InvitationCodeRegex();

Nas expressões regulares em C# usadas para extração, como DomainExtractorRegex e UrlRegex, a âncora no final atrapalha. Isso acontece porque o padrão precisa encontrar apenas uma parte de um texto maior.

Ainda assim, a âncora ^ pode ser útil nesses casos. Com ela, o padrão começa a correspondência obrigatoriamente no início da string.

Desempenho e segurança

Organizar bem os padrões também ajuda a controlar riscos. As boas práticas de Regex no .NET alertam que entradas não confiáveis podem causar ataques de negação de serviço por backtracking excessivo.

Por isso, quando um padrão processa texto vindo do usuário, vale definir um tempo limite. O atributo [GeneratedRegex] aceita esse valor em milissegundos, como mostra o exemplo.

// Tempo limite de 200 ms para entradas vindas do usuário
[GeneratedRegex(@"^[\p{L} ]+$", RegexOptions.None, matchTimeoutMilliseconds: 200)]
public static partial Regex SafeNameRegex();

Se o motor ultrapassar o limite, ele lança RegexMatchTimeoutException. Então, o código chamador decide se registra o erro, rejeita a entrada ou tenta de novo.

Além disso, nunca monte um padrão a partir de texto digitado pelo usuário. Quando precisar incluir esse texto em uma busca, use Regex.Escape para tratá-lo como literal.

Testes de unidade para expressões regulares em C#

As expressões regulares em C# devem passar por testes isolados, independentes dos métodos que as utilizam. Esses testes têm dois objetivos: documentar o que cada padrão aceita e rejeita, e impedir que uma mudança futura quebre o comportamento em silêncio.

A estrutura recomendada usa classes aninhadas, uma por padrão, com casos positivos e negativos separados. O exemplo usa NUnit, mas a ideia vale para qualquer framework.

Para revisar a base do assunto, leia os fundamentos dos testes de unidade.

[TestFixture]
public class RegularExpressionTests
{
    [TestFixture]
    public class InvitationCodeRegexTests
    {
        [Test(Description = "Should match a valid 8 character alphanumeric code")]
        [TestCase("ABCD1234")]
        [TestCase("12345678")]
        [TestCase("ABCDEFGH")]
        public void InvitationCodeRegex_ShouldMatch_ValidCode(string value)
            => Assert.That(RegularExpression.InvitationCodeRegex().IsMatch(value), Is.True);

        [Test(Description = "Should not match a code with lowercase letters")]
        [TestCase("abcd1234")]
        [TestCase("ABCDabcd")]
        public void InvitationCodeRegex_ShouldNotMatch_LowercaseLetters(string value)
            => Assert.That(RegularExpression.InvitationCodeRegex().IsMatch(value), Is.False);

        [Test(Description = "Should not match a code shorter than 8 characters")]
        [TestCase("ABCD123")]
        public void InvitationCodeRegex_ShouldNotMatch_TooShort(string value)
            => Assert.That(RegularExpression.InvitationCodeRegex().IsMatch(value), Is.False);

        [Test(Description = "Should not match a code longer than 8 characters")]
        [TestCase("ABCD12345")]
        public void InvitationCodeRegex_ShouldNotMatch_TooLong(string value)
            => Assert.That(RegularExpression.InvitationCodeRegex().IsMatch(value), Is.False);
    }

    [TestFixture]
    public class DomainExtractorRegexTests
    {
        [Test(Description = "Should extract domain from capture group")]
        [TestCase("https://explanna.com/blog", "explanna.com")]
        [TestCase("https://www.microsoft.com", "microsoft.com")]
        [TestCase("https://mail.google.com", "mail.google.com")]
        public void DomainExtractorRegex_ShouldExtract_DomainFromUrl(string input, string expected)
        {
            var match = RegularExpression.DomainExtractorRegex().Match(input);
            Assert.That(match.Groups[1].Value, Is.EqualTo(expected));
        }
    }
}

Testando grupos de captura

Nos padrões de extração, os testes mais valiosos não usam IsMatch. Na verdade, eles verificam o conteúdo do grupo de captura.

Um padrão pode corresponder à string sem extrair o fragmento correto. Portanto, apenas testes sobre Groups[n].Value detectam esse tipo de falha.

Esses testes complementam, mas não substituem, os testes dos métodos de extensão. Afinal, os métodos verificam o comportamento completo, incluindo a normalização antes do match e o tratamento do resultado depois.

Documentação inline

Expressões regulares têm fama de serem difíceis de ler. Por isso, a documentação com <summary> se torna obrigatória para qualquer padrão não trivial.

Para padrões complexos, inclua também exemplos válidos e inválidos. O trecho abaixo mostra como fica a documentação de NameRegex.

/// <summary>
/// Valida que um nome possui pelo menos duas palavras, cada uma com no mínimo
/// 2 caracteres, permitindo letras Unicode e hífens dentro das palavras.
/// Exemplos válidos: "João Silva", "Ana-Paula Souza", "Renée Müller"
/// Exemplos inválidos: "Madonna", "João B", "Jo4o Silva"
/// </summary>
[GeneratedRegex(@"^(?:\p{L}[\p{L}-]+)(?:\s+(?:\p{L}[\p{L}-]+))+$")]
public static partial Regex NameRegex();

Com exemplos no <summary>, a intenção do padrão fica clara mesmo para quem não domina a sintaxe. Além disso, o gerador do .NET emite comentários XML que descrevem a lógica gerada, e você pode navegar até esse código com Go To Definition no Visual Studio.

Quando não usar GeneratedRegex nas expressões regulares em C#

A orientação geral da Microsoft é simples: se você pode usar o source generator, use. Ainda assim, algumas expressões regulares em C# não se encaixam nesse modelo.

O primeiro caso é o padrão que só existe em tempo de execução. Como o gerador precisa conhecer o texto do padrão no build, um padrão lido de configuração ou de banco de dados continua exigindo new Regex(...).

O segundo caso é o tamanho do assembly. O gerador emite código C# para cada padrão, então um projeto com centenas de expressões grandes pode crescer de forma perceptível.

Além disso, um padrão usado raramente, em que o throughput não importa, pode rodar no interpretador sem prejuízo. Nesse caso, o ganho do gerador quase não aparece.

Mesmo com a opção RegexOptions.NonBacktracking, para a qual o gerador não cria uma implementação customizada, ele ainda emite o cache e os comentários XML. Portanto, a classe central continua valendo a pena para manter as expressões regulares em C# organizadas.

Checklist para organizar expressões regulares em C#

Antes de adicionar um novo padrão ao projeto, vale passar por uma lista curta de verificação. Assim, as expressões regulares em C# da equipe seguem o mesmo padrão de qualidade.

  • Já existe um padrão igual na classe RegularExpression?
  • O nome descreve a ação ou o conceito validado?
  • Cada <summary> traz exemplos válidos e inválidos?
  • A validação usa as âncoras ^ e $?
  • A escolha entre ASCII e Unicode está documentada?
  • Há entrada do usuário que exige tempo limite?
  • Existem testes positivos e negativos para ele?

Se todas as respostas estiverem claras, o padrão está pronto para entrar na base de código. Caso contrário, ajuste o ponto pendente antes do merge.

Conclusão

Organizar expressões regulares em C# em uma classe central, com [GeneratedRegex], métodos de extensão bem definidos e testes isolados, transforma uma parte frágil do código em algo previsível e reutilizável. Assim, cada padrão ganha um único lugar para existir.

A separação em três camadas (definição, comportamento e uso) dá a cada artefato uma responsabilidade clara. Dessa forma, uma alteração em um padrão fica visível para todos os consumidores, e os testes impedem regressões silenciosas.

Por fim, a escolha consciente entre ASCII e Unicode, o uso correto de âncoras, o tempo limite e a documentação inline completam o conjunto de práticas. São esses cuidados que separam expressões regulares em C# bem projetadas de padrões obscuros espalhados pelo código.

FAQ

A partir de qual versão do .NET posso usar GeneratedRegex?

O atributo [GeneratedRegex] chegou no .NET 7. A partir do .NET 9, com C# 13, ele também funciona em propriedades parciais.

Preciso usar RegexOptions.Compiled junto com GeneratedRegex?

Não, porque o source generator ignora essa flag. A compilação já acontece no build, então a opção se torna redundante.

Por que usar \p{L} em vez de [a-zA-Z]?

O padrão [a-zA-Z] aceita apenas o alfabeto latino básico e rejeita nomes como João ou Müller. Já \p{L} aceita qualquer letra Unicode.

Devo testar o padrão ou o método que o utiliza?

Os dois. Os testes do padrão documentam o que ele aceita e rejeita, enquanto os testes do método validam o comportamento completo.

Como evitar travamentos com entradas maliciosas?

Defina um tempo limite com matchTimeoutMilliseconds e evite padrões com backtracking excessivo. Além disso, nunca monte padrões a partir de texto do usuário sem Regex.Escape.