DEV Community

Alair Joao Tavares
Alair Joao Tavares

Posted on • Originally published at activi.dev

Desacoplando Serviços Externos: Implementando o Provider Pattern para Integração de Assinatura Eletrônica

O Problema que me Fez Repensar Tudo

Eu estava no meio de uma tarefa que parecia simples: adicionar o DocuSign como um segundo provedor de assinatura eletrônica em um sistema que já usava outro serviço. A lógica de negócio para envio de documentos, controle de status e callbacks já existia — e estava completamente entrelaçada com o provedor original.

O que deveria ser uma adição cirúrgica virou um exercício de arqueologia de código. Cada ponto de integração estava espalhado por views, tasks Celery e services Django, todos chamando diretamente a API do provedor original. Adicionar um segundo provedor sem refatorar significava duplicar lógica, criar condicionais if provider == 'docusign' por todo o codebase, e tornar qualquer terceiro provedor no futuro ainda mais doloroso.

Foi nesse momento que decidi parar, respirar, e implementar o Provider Pattern de forma adequada. Neste artigo, vou mostrar exatamente como estruturei essa abstração em Django, usando a integração com DocuSign como exemplo concreto.


O que é o Provider Pattern e por que ele resolve esse problema

O Provider Pattern (às vezes chamado de Strategy Pattern no contexto de comportamentos intercambiáveis) é uma forma de encapsular implementações concretas atrás de uma interface comum. A ideia central é simples: o código de orquestração não precisa saber qual provedor está sendo usado — ele só precisa saber o que o provedor é capaz de fazer.

No meu caso, a interface precisava responder a três perguntas:

  1. Como enviar um documento para assinatura?
  2. Como consultar o status de uma assinatura?
  3. Como processar um webhook/callback de atualização?

Qualquer provedor — DocuSign, outro serviço ou até um mock para testes — precisa responder a essas três perguntas da mesma forma, do ponto de vista do código que os consome.

A Estrutura de Diretórios

Antes de escrever qualquer código, organizei os arquivos assim:

esignature/
├── __init__.py
├── providers/
│   ├── __init__.py
│   ├── base.py          # Interface abstrata
│   ├── docusign.py      # Implementação DocuSign
│   └── mock.py          # Implementação para testes
├── registry.py          # Registro de provedores disponíveis
├── service.py           # Orquestrador principal
└── models.py            # Models de domínio (envelope, status, etc.)
Enter fullscreen mode Exit fullscreen mode

Essa separação foi intencional: cada arquivo tem uma responsabilidade única e o service.py nunca importa diretamente nenhum provedor concreto.


Definindo a Interface Abstrata

O primeiro passo foi criar a classe base. Em Python, uso abc.ABC e abc.abstractmethod para garantir que qualquer provedor que esqueça de implementar um método falhe em tempo de instanciação, não em tempo de execução.

# esignature/providers/base.py

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Optional


@dataclass
class SignatureRequest:
    """Representa uma solicitação de assinatura normalizada."""
    document_id: str
    document_name: str
    document_content: bytes  # PDF em bytes
    signer_email: str
    signer_name: str
    subject: str
    message: Optional[str] = None
    redirect_url: Optional[str] = None


@dataclass
class SignatureResult:
    """Resultado retornado após o envio bem-sucedido."""
    external_envelope_id: str   # ID gerado pelo provedor externo
    signing_url: Optional[str]  # URL para assinatura embedded, se aplicável
    status: str                 # 'sent', 'delivered', etc.
    raw_response: dict          # Resposta bruta para auditoria


@dataclass
class SignatureStatus:
    """Status atual de um envelope já enviado."""
    external_envelope_id: str
    status: str                 # 'sent', 'delivered', 'completed', 'declined', 'voided'
    completed_at: Optional[datetime]
    declined_reason: Optional[str]
    raw_response: dict


class BaseESignatureProvider(ABC):
    """Interface que todo provedor de assinatura eletrônica deve implementar."""

    @abstractmethod
    def send_for_signature(self, request: SignatureRequest) -> SignatureResult:
        """
        Envia um documento para assinatura.
        Deve retornar um SignatureResult com o ID externo do envelope.
        """
        raise NotImplementedError

    @abstractmethod
    def get_envelope_status(self, external_envelope_id: str) -> SignatureStatus:
        """
        Consulta o status atual de um envelope pelo ID externo.
        """
        raise NotImplementedError

    @abstractmethod
    def process_webhook_payload(self, payload: dict) -> SignatureStatus:
        """
        Processa o payload recebido de um webhook do provedor
        e retorna um SignatureStatus normalizado.
        """
        raise NotImplementedError

    @property
    @abstractmethod
    def provider_name(self) -> str:
        """Identificador único deste provedor (ex: 'docusign')."""
        raise NotImplementedError
Enter fullscreen mode Exit fullscreen mode

Perceba que os métodos trabalham com dataclasses de domínio próprio (SignatureRequest, SignatureResult, SignatureStatus), não com os tipos nativos de cada SDK. Isso é crucial: o service.py fala a língua do meu domínio, não a língua do DocuSign.


Implementando o Provedor DocuSign

Com a interface definida, implementei o provedor concreto do DocuSign. Aqui é onde a complexidade do SDK externo fica isolada:

# esignature/providers/docusign.py

import base64
from datetime import datetime
from typing import Optional

import docusign_esign as docusign
from django.conf import settings

from .base import (
    BaseESignatureProvider,
    SignatureRequest,
    SignatureResult,
    SignatureStatus,
)


class DocuSignProvider(BaseESignatureProvider):
    """
    Implementação do provedor de assinatura usando a API do DocuSign.
    Toda a complexidade do SDK fica confinada aqui.
    """

    provider_name = 'docusign'

    def __init__(self):
        self._client = None

    def _get_api_client(self) -> docusign.ApiClient:
        """Inicializa e autentica o cliente DocuSign via JWT."""
        if self._client is not None:
            return self._client

        api_client = docusign.ApiClient()
        api_client.set_base_path(settings.DOCUSIGN_BASE_URL)

        # Autenticação JWT — as credenciais vêm de variáveis de ambiente
        api_client.configure_jwt_authorization_flow(
            private_key_bytes=settings.DOCUSIGN_PRIVATE_KEY.encode(),
            oauth_host_name=settings.DOCUSIGN_OAUTH_HOST,
            client_id=settings.DOCUSIGN_CLIENT_ID,
            user_id=settings.DOCUSIGN_IMPERSONATED_USER_ID,
            expires_in=3600,
        )

        self._client = api_client
        return self._client

    def send_for_signature(self, request: SignatureRequest) -> SignatureResult:
        api_client = self._get_api_client()
        envelopes_api = docusign.EnvelopesApi(api_client)

        # Monta o documento no formato esperado pelo DocuSign
        document = docusign.Document(
            document_base64=base64.b64encode(request.document_content).decode('ascii'),
            name=request.document_name,
            file_extension='pdf',
            document_id='1',
        )

        # Configura o signatário
        signer = docusign.Signer(
            email=request.signer_email,
            name=request.signer_name,
            recipient_id='1',
            routing_order='1',
        )

        # Adiciona uma aba de assinatura no final do documento
        sign_here = docusign.SignHere(
            anchor_string='/sig1/',
            anchor_units='pixels',
            anchor_y_offset='10',
            anchor_x_offset='20',
        )
        signer.tabs = docusign.Tabs(sign_here_tabs=[sign_here])

        envelope_definition = docusign.EnvelopeDefinition(
            email_subject=request.subject,
            email_blurb=request.message or '',
            documents=[document],
            recipients=docusign.Recipients(signers=[signer]),
            status='sent',
        )

        result = envelopes_api.create_envelope(
            account_id=settings.DOCUSIGN_ACCOUNT_ID,
            envelope_definition=envelope_definition,
        )

        return SignatureResult(
            external_envelope_id=result.envelope_id,
            signing_url=None,  # Para fluxo embedded, geraria a URL aqui
            status=result.status,
            raw_response=result.to_dict(),
        )

    def get_envelope_status(self, external_envelope_id: str) -> SignatureStatus:
        api_client = self._get_api_client()
        envelopes_api = docusign.EnvelopesApi(api_client)

        envelope = envelopes_api.get_envelope(
            account_id=settings.DOCUSIGN_ACCOUNT_ID,
            envelope_id=external_envelope_id,
        )

        completed_at = None
        if envelope.completed_date_time:
            completed_at = datetime.fromisoformat(
                envelope.completed_date_time.replace('Z', '+00:00')
            )

        return SignatureStatus(
            external_envelope_id=external_envelope_id,
            status=envelope.status,
            completed_at=completed_at,
            declined_reason=None,  # Buscaria dos recipients em caso de 'declined'
            raw_response=envelope.to_dict(),
        )

    def process_webhook_payload(self, payload: dict) -> SignatureStatus:
        """
        O DocuSign envia Connect webhooks com uma estrutura específica.
        Aqui normalizamos para nosso formato interno.
        """
        envelope_id = payload.get('envelopeId') or payload.get('EnvelopeID', '')
        status = (payload.get('status') or payload.get('Status', '')).lower()

        completed_at = None
        if status == 'completed' and payload.get('completedDateTime'):
            completed_at = datetime.fromisoformat(
                payload['completedDateTime'].replace('Z', '+00:00')
            )

        return SignatureStatus(
            external_envelope_id=envelope_id,
            status=status,
            completed_at=completed_at,
            declined_reason=payload.get('declinedReason'),
            raw_response=payload,
        )
Enter fullscreen mode Exit fullscreen mode

Também criei um provedor mock para testes, que segue a mesma interface mas nunca faz chamadas externas:

# esignature/providers/mock.py

import uuid
from datetime import datetime, timezone

from .base import (
    BaseESignatureProvider,
    SignatureRequest,
    SignatureResult,
    SignatureStatus,
)


class MockESignatureProvider(BaseESignatureProvider):
    """Provedor falso para uso em testes e ambientes de desenvolvimento."""

    provider_name = 'mock'

    def send_for_signature(self, request: SignatureRequest) -> SignatureResult:
        fake_id = str(uuid.uuid4())
        return SignatureResult(
            external_envelope_id=fake_id,
            signing_url=f'https://mock.example.com/sign/{fake_id}',
            status='sent',
            raw_response={'mock': True, 'envelope_id': fake_id},
        )

    def get_envelope_status(self, external_envelope_id: str) -> SignatureStatus:
        return SignatureStatus(
            external_envelope_id=external_envelope_id,
            status='completed',
            completed_at=datetime.now(tz=timezone.utc),
            declined_reason=None,
            raw_response={'mock': True},
        )

    def process_webhook_payload(self, payload: dict) -> SignatureStatus:
        return SignatureStatus(
            external_envelope_id=payload.get('envelope_id', 'mock-id'),
            status=payload.get('status', 'completed'),
            completed_at=datetime.now(tz=timezone.utc),
            declined_reason=None,
            raw_response=payload,
        )
Enter fullscreen mode Exit fullscreen mode

O Registro e o Service Layer

Com os provedores definidos, precisei de uma forma de selecionar o correto em runtime. Criei um registro simples controlado por configuração:

# esignature/registry.py

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured

from .providers.base import BaseESignatureProvider
from .providers.docusign import DocuSignProvider
from .providers.mock import MockESignatureProvider

_PROVIDER_MAP = {
    'docusign': DocuSignProvider,
    'mock': MockESignatureProvider,
}


def get_esignature_provider() -> BaseESignatureProvider:
    """
    Retorna uma instância do provedor configurado em settings.

    Configure em settings.py:
        ESIGNATURE_PROVIDER = 'docusign'  # ou 'mock' para testes
    """
    provider_key = getattr(settings, 'ESIGNATURE_PROVIDER', None)

    if not provider_key:
        raise ImproperlyConfigured(
            'A configuração ESIGNATURE_PROVIDER não está definida em settings.'
        )

    provider_class = _PROVIDER_MAP.get(provider_key)

    if not provider_class:
        available = ', '.join(_PROVIDER_MAP.keys())
        raise ImproperlyConfigured(
            f'Provedor "{provider_key}" não encontrado. '
            f'Provedores disponíveis: {available}'
        )

    return provider_class()
Enter fullscreen mode Exit fullscreen mode

E o service layer que o resto da aplicação usa — este é o único ponto de entrada público:

# esignature/service.py

import logging
from typing import Optional

from .models import DocumentEnvelope
from .providers.base import SignatureRequest, SignatureStatus
from .registry import get_esignature_provider

logger = logging.getLogger(__name__)


class ESignatureService:
    """
    Orquestrador principal. Não conhece nenhum provedor concreto.
    Toda a lógica de negócio vive aqui; a lógica de integração vive nos providers.
    """

    def __init__(self):
        self.provider = get_esignature_provider()

    def send_document_for_signature(
        self,
        document_id: str,
        document_name: str,
        document_content: bytes,
        signer_email: str,
        signer_name: str,
    ) -> DocumentEnvelope:
        """
        Envia um documento para assinatura e persiste o resultado.
        """
        request = SignatureRequest(
            document_id=document_id,
            document_name=document_name,
            document_content=document_content,
            signer_email=signer_email,
            signer_name=signer_name,
            subject=f'Documento para assinatura: {document_name}',
        )

        logger.info(
            'Enviando documento para assinatura via provedor %s',
            self.provider.provider_name,
        )

        result = self.provider.send_for_signature(request)

        # Persiste o envelope no banco com o ID externo
        envelope = DocumentEnvelope.objects.create(
            document_id=document_id,
            provider=self.provider.provider_name,
            external_envelope_id=result.external_envelope_id,
            status=result.status,
            signing_url=result.signing_url,
            raw_provider_response=result.raw_response,
        )

        logger.info(
            'Envelope %s criado com sucesso (externo: %s)',
            envelope.pk,
            envelope.external_envelope_id,
        )

        return envelope

    def handle_webhook(self, payload: dict, provider_name: str) -> Optional[DocumentEnvelope]:
        """
        Processa um webhook recebido e atualiza o status do envelope.
        """
        status: SignatureStatus = self.provider.process_webhook_payload(payload)

        try:
            envelope = DocumentEnvelope.objects.get(
                external_envelope_id=status.external_envelope_id,
                provider=provider_name,
            )
        except DocumentEnvelope.DoesNotExist:
            logger.warning(
                'Webhook recebido para envelope desconhecido: %s',
                status.external_envelope_id,
            )
            return None

        envelope.status = status.status
        envelope.completed_at = status.completed_at
        envelope.raw_provider_response = status.raw_response
        envelope.save(update_fields=['status', 'completed_at', 'raw_provider_response'])

        return envelope
Enter fullscreen mode Exit fullscreen mode

Boas Práticas que Aprendi na Prática

Depois de implementar e iterar sobre esse padrão, algumas lições ficaram bem claras:

1. Nunca exponha tipos do SDK externo fora do provider. Se o docusign.Envelope vazar para o service.py, você criou um acoplamento escondido. As dataclasses de domínio (SignatureResult, SignatureStatus) são o contrato, não os tipos do SDK.

2. O campo raw_response é seu melhor amigo para debugging. Sempre persisto a resposta bruta do provedor no banco. Quando um webhook chega com um status inesperado, ter o payload original salvo economiza horas de investigação.

3. Configure o provedor via settings.py, não via código. Trocar ESIGNATURE_PROVIDER = 'mock' em settings_test.py faz todos os testes rodarem sem nenhuma chamada externa. Sem mocks, sem patches — só configuração.

4. Use variáveis de ambiente para credenciais do provedor, nunca hardcode. Cada provedor tem suas próprias credenciais. Eu as injeto via variáveis de ambiente e as leio em settings.py:

# settings.py
import os

ESIGNATURE_PROVIDER = os.environ.get('ESIGNATURE_PROVIDER', 'mock')

# Credenciais DocuSign
DOCUSIGN_CLIENT_ID = os.environ.get('DOCUSIGN_CLIENT_ID', '')
DOCUSIGN_ACCOUNT_ID = os.environ.get('DOCUSIGN_ACCOUNT_ID', '')
DOCUSIGN_IMPERSONATED_USER_ID = os.environ.get('DOCUSIGN_IMPERSONATED_USER_ID', '')
DOCUSIGN_PRIVATE_KEY = os.environ.get('DOCUSIGN_PRIVATE_KEY', '')
DOCUSIGN_BASE_URL = os.environ.get('DOCUSIGN_BASE_URL', 'https://demo.docusign.net/restapi')
DOCUSIGN_OAUTH_HOST = os.environ.get('DOCUSIGN_OAUTH_HOST', 'account-d.docusign.com')
Enter fullscreen mode Exit fullscreen mode

5. Teste o contrato, não a implementação. Escreva testes contra a interface BaseESignatureProvider, não contra DocuSignProvider diretamente:

# tests/test_esignature_service.py

from django.test import TestCase, override_settings

from esignature.service import ESignatureService


@override_settings(ESIGNATURE_PROVIDER='mock')
class ESignatureServiceTests(TestCase):

    def setUp(self):
        self.service = ESignatureService()

    def test_send_document_creates_envelope(self):
        envelope = self.service.send_document_for_signature(
            document_id='doc-123',
            document_name='Contrato de Prestação de Serviços',
            document_content=b'%PDF-1.4 fake content',
            signer_email='cliente@example.com',
            signer_name='João da Silva',
        )

        self.assertIsNotNone(envelope.external_envelope_id)
        self.assertEqual(envelope.provider, 'mock')
        self.assertEqual(envelope.status, 'sent')

    def test_webhook_updates_envelope_status(self):
        envelope = self.service.send_document_for_signature(
            document_id='doc-456',
            document_name='Termo de Aceite',
            document_content=b'%PDF-1.4 fake content',
            signer_email='cliente@example.com',
            signer_name='Maria Oliveira',
        )

        webhook_payload = {
            'envelope_id': envelope.external_envelope_id,
            'status': 'completed',
        }

        updated = self.service.handle_webhook(webhook_payload, provider_name='mock')
        self.assertEqual(updated.status, 'completed')
Enter fullscreen mode Exit fullscreen mode

Conclusão

Quando terminei essa refatoração, adicionar o DocuSign foi questão de escrever um único arquivo — docusign.py — sem tocar em nenhuma outra parte do sistema. Se amanhã eu precisar integrar um terceiro provedor, o processo é idêntico: implementar BaseESignatureProvider, registrar no _PROVIDER_MAP, e alterar uma variável de ambiente.

Os principais aprendizados que levo dessa experiência:

  • O Provider Pattern isola a complexidade dos SDKs externos no lugar certo — dentro do provedor, não espalhada pelo codebase.
  • Dataclasses de domínio são a cola que permite que provedores completamente diferentes falem a mesma língua para o código de orquestração.
  • Registro controlado por configuração torna o swap de provedores uma operação de infraestrutura, não de código.
  • O provedor mock não é apenas para testes — é a documentação viva de como um novo provedor deve se comportar.

Se você está integrando qualquer serviço externo que pode potencialmente ser trocado no futuro — pagamentos, notificações, armazenamento, assinatura eletrônica — esse padrão vai te poupar dores de cabeça consideráveis lá na frente.

Top comments (0)