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:
- Como enviar um documento para assinatura?
- Como consultar o status de uma assinatura?
- 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.)
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
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,
)
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,
)
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()
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
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')
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')
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)