> For the complete documentation index, see [llms.txt](https://docs.skail.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.skail.dev/construa/como-fazer/escrever-commands-idempotentes.md).

# Escrever commands idempotentes

O skail garante que um command não executa de novo depois de gravar o resultado. Ele não pode garantir que a operação externa aconteceu uma única vez se o command falhar entre a operação externa e a gravação. Este guia mostra como fechar essa janela com uma chave de negócio, em três situações.

## Por que a janela existe

Um command chama o gateway de pagamento. O gateway cobra e responde 200. Antes de o runtime gravar o resultado no histórico, o processo cai. Na reentrega, o replay chega ao command sem passo gravado e o executa de novo, o que gera uma segunda cobrança. A janela dura milissegundos e é rara, mas em escala ela acontece, e é a mesma em qualquer sistema distribuído. Ver [Falhas, retries e idempotência](/construa/conceitos/retries-e-idempotencia.md).

A solução é sempre a mesma: a chamada externa leva uma chave que identifica a operação de negócio, e o sistema externo reconhece a repetição.

## Caso 1: o sistema externo aceita chave de idempotência

Gateways de pagamento, provedores de e-mail e APIs bem desenhadas aceitam um header ou campo de idempotência. Use o id de negócio da operação, não um valor gerado na hora:

{% tabs %}
{% tab title="C#" %}

```csharp
[SkailCommand(retryCount: 8)]
public async SkailTask<ResultadoCobranca> CobrarCartao(Guid faturaId, decimal valor)
{
    using var request = new HttpRequestMessage(HttpMethod.Post, "/charges")
    {
        Content = JsonContent.Create(new { amount = valor, invoice = faturaId })
    };
    request.Headers.Add("Idempotency-Key", $"cobranca-{faturaId}");   // mesma chave em toda tentativa

    var resposta = await _gateway.SendAsync(request);
    resposta.EnsureSuccessStatusCode();

    var corpo = await resposta.Content.ReadFromJsonAsync<RespostaGateway>();
    return corpo!.Approved ? ResultadoCobranca.Aprovado : ResultadoCobranca.Recusado;
}
```

{% endtab %}

{% tab title="TypeScript" %}

```typescript
import { SkailCommand } from "@skail/runtime";

export type ResultadoCobranca = "Aprovado" | "Recusado";

interface RespostaGateway {
  approved: boolean;
}

export class CobrancaCommands {
  @SkailCommand({ retryCount: 8 })
  async CobrarCartao(faturaId: string, valor: number): Promise<ResultadoCobranca> {
    const resposta = await fetch(`${process.env.GATEWAY_URL}/charges`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": `cobranca-${faturaId}`,   // mesma chave em toda tentativa
      },
      body: JSON.stringify({ amount: valor, invoice: faturaId }),
    });
    if (!resposta.ok) throw new Error(`gateway respondeu ${resposta.status}`);

    const corpo = (await resposta.json()) as RespostaGateway;
    return corpo.approved ? "Aprovado" : "Recusado";
  }
}
```

{% endtab %}
{% endtabs %}

A chave é derivada dos argumentos (`faturaId`), então é idêntica em toda tentativa. Um id gerado na hora como chave seria diferente a cada tentativa e não protegeria nada. Se a mesma fatura pode ser cobrada mais de uma vez legitimamente (retentativa de negócio dias depois), inclua o número da tentativa na chave: `cobranca-{faturaId}-{tentativa}`, com `tentativa` vindo como argumento.

## Caso 2: o efeito é no seu banco

Escrita no próprio banco: use upsert por chave natural, ou insert com chave única e trate a violação como sucesso.

{% tabs %}
{% tab title="C#" %}

```csharp
[SkailCommand]
public async SkailTask RegistrarEmissao(Guid pedidoId, string chaveNfe)
{
    await _db.Database.ExecuteSqlInterpolatedAsync($@"
        INSERT INTO emissoes (pedido_id, chave_nfe, emitido_em)
        VALUES ({pedidoId}, {chaveNfe}, now())
        ON CONFLICT (pedido_id) DO NOTHING");
}
```

{% endtab %}

{% tab title="TypeScript" %}

```typescript
import { SkailCommand } from "@skail/runtime";

export class EmissaoCommands {
  @SkailCommand()
  async RegistrarEmissao(pedidoId: string, chaveNfe: string): Promise<void> {
    const { Pool } = await import("pg");
    const db = new Pool({ connectionString: process.env.DATABASE_URL });
    try {
      await db.query(
        `INSERT INTO emissoes (pedido_id, chave_nfe, emitido_em)
         VALUES ($1, $2, now())
         ON CONFLICT (pedido_id) DO NOTHING`,
        [pedidoId, chaveNfe],
      );
    } finally {
      await db.end();
    }
  }
}
```

{% endtab %}
{% endtabs %}

Repetir o command produz o mesmo estado. Evite `INSERT` puro em tabelas sem chave única e evite `UPDATE saldo = saldo + x`: prefira gravar o fato (`lancamentos` com chave única por operação) e derivar o saldo.

## Caso 3: o sistema externo não aceita chave

Quando o destino é um ERP antigo ou um serviço de terceiros sem idempotência, a idempotência fica por sua conta, com uma tabela de efeitos aplicados, consultada antes e gravada depois, dentro do mesmo command:

{% tabs %}
{% tab title="C#" %}

```csharp
[SkailCommand(retryCount: 3)]
public async SkailTask<string> EmitirNoErp(Guid pedidoId)
{
    var jaEmitida = await _db.EfeitosAplicados
        .Where(e => e.Chave == $"emissao-erp-{pedidoId}")
        .Select(e => e.Resultado)
        .FirstOrDefaultAsync();
    if (jaEmitida is not null) return jaEmitida;              // já aconteceu: devolve o mesmo resultado

    var numero = await _erp.EmitirAsync(pedidoId);            // o efeito

    _db.EfeitosAplicados.Add(new EfeitoAplicado($"emissao-erp-{pedidoId}", numero));
    await _db.SaveChangesAsync();
    return numero;
}
```

{% endtab %}

{% tab title="TypeScript" %}

```typescript
import { SkailCommand } from "@skail/runtime";

export class ErpCommands {
  @SkailCommand({ retryCount: 3 })
  async EmitirNoErp(pedidoId: string): Promise<string> {
    const { Pool } = await import("pg");
    const db = new Pool({ connectionString: process.env.DATABASE_URL });
    try {
      const chave = `emissao-erp-${pedidoId}`;
      const existente = await db.query<{ resultado: string }>(
        "SELECT resultado FROM efeitos_aplicados WHERE chave = $1 LIMIT 1",
        [chave],
      );
      const jaEmitida = existente.rows[0]?.resultado;
      if (jaEmitida !== undefined) return jaEmitida;          // já aconteceu: devolve o mesmo resultado

      const numero = await emitirNoErp(pedidoId);             // o efeito

      await db.query("INSERT INTO efeitos_aplicados (chave, resultado) VALUES ($1, $2)", [chave, numero]);
      return numero;
    } finally {
      await db.end();
    }
  }
}

async function emitirNoErp(pedidoId: string): Promise<string> {
  const resposta = await fetch(`${process.env.ERP_URL}/emissoes`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ pedidoId }),
  });
  if (!resposta.ok) throw new Error(`ERP respondeu ${resposta.status}`);
  return ((await resposta.json()) as { numero: string }).numero;
}
```

{% endtab %}
{% endtabs %}

{% hint style="warning" %}
Ainda há uma janela entre a emissão no ERP e a gravação na tabela de efeitos aplicados, menor que a original e sob seu controle; se o ERP oferece alguma consulta por referência externa, use-a no lugar da tabela. Para que a decisão de repetir depois de uma falha seja humana, use também `retryCount: 0` e trate a falha na function. O zero não substitui a tabela: se o processo cair depois da emissão, o command roda de novo na retomada.
{% endhint %}

## retryCount como parte da idempotência

`retryCount` alto (8, 10) para operações externas idempotentes contra integrações instáveis: repetir é seguro e barato. `retryCount: 0` quando a function precisa tratar a exceção na primeira falha: operação que não pode ser repetida sem alguém decidir, falha de negócio sinalizada por exceção, caminho alternativo como contingência. A function decide (compensar, marcar pendente, avisar alguém, seguir pela contingência). O zero só evita a nova tentativa por exceção. Se o processo cair entre a operação externa e a gravação do resultado, o command executa de novo na retomada, então a chave ou a tabela de efeitos aplicados continua necessária com qualquer `retryCount`. Escolha o `retryCount` de acordo com o caso, sem depender do valor padrão.

Se a function tenta de novo num laço (loop), a chave segue a regra do Caso 1: a mesma quando a tentativa repete a mesma operação, com o número da tentativa quando cada tentativa é uma operação nova.

## Checklist

* Toda chamada externa com efeito leva uma chave derivada dos argumentos do command.
* Escrita no banco é upsert por chave natural ou insert com chave única.
* Sem chave possível: tabela de efeitos aplicados no mesmo command, com `retryCount: 0` se a decisão de repetir precisa ser humana.
* Falha de negócio é resultado; com o retry ligado, exceção só para o que pode dar certo na próxima tentativa.

## Próximos passos

[Como tratar erros e compensar](/construa/como-fazer/tratar-erros-e-desfazer-passos.md) para quando a operação não pode ser desfeita e precisa ser compensada. [Saga](/construa/como-fazer/padroes-e-antipadroes/saga.md) para o padrão completo. A referência de [SkailCommand](/referencia/sdk-csharp/skailcommand.md) no SDK C#, para `retryCount`.
