The Problem With Hardcoding Third-Party Integrations
Not long ago, I finished integrating a second e-signature provider into one of my Django projects. The first provider had been baked in for months — function calls scattered across service modules, API response parsing tangled up with business logic, and environment variables with the provider's name literally in the key. It worked, but it was the kind of code that makes you nervous every time requirements change.
When the requirement came to add DocuSign alongside the existing provider, I had two options: fork the logic everywhere and double down on the mess, or stop and design a proper abstraction. I chose the latter, and what I landed on was the Provider Pattern — a clean interface that lets me plug in any e-signature backend without touching the orchestration layer at all.
In this article, I'll walk through how I structured the abstraction, how I made Django's settings system do the heavy lifting for provider selection, and what I learned along the way about keeping third-party integrations from colonizing your codebase.
Designing the Abstract Interface First
The core idea behind the provider pattern is simple: define a contract that all providers must fulfill, then write your business logic against that contract — never against a specific implementation.
I started by asking myself: what does an e-signature provider actually do from my application's perspective? Stripping away DocuSign-specific concepts, I landed on three operations:
- Create an envelope (a signable document package)
- Get the signing URL for a recipient
- Check the status of an envelope
Everything else — authentication flows, webhook payloads, API versioning — is the provider's problem, not my application's.
Here's the abstract base class I wrote:
# esignature/providers/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
@dataclass
class SigningRecipient:
name: str
email: str
recipient_id: str = "1"
@dataclass
class EnvelopeResult:
envelope_id: str
status: str
provider: str
@dataclass
class SigningUrlResult:
url: str
expires_at: Optional[str] = None
class BaseESignatureProvider(ABC):
"""
Abstract interface for e-signature providers.
All concrete providers must implement these methods.
"""
@abstractmethod
def create_envelope(
self,
document_name: str,
document_bytes: bytes,
recipients: list[SigningRecipient],
subject: str = "Please sign this document",
) -> EnvelopeResult:
"""
Upload a document and create a signing envelope.
Returns an EnvelopeResult with the provider's envelope identifier.
"""
...
@abstractmethod
def get_signing_url(
self,
envelope_id: str,
recipient: SigningRecipient,
return_url: str,
) -> SigningUrlResult:
"""
Generate an embedded signing URL for a specific recipient.
"""
...
@abstractmethod
def get_envelope_status(self, envelope_id: str) -> str:
"""
Returns a normalized status string: 'sent', 'completed', 'voided', 'declined'.
"""
...
Notice that I'm using plain dataclasses for the return types rather than provider-specific response objects. This is deliberate. My application code should never see a DocuSign EnvelopeSummary or any other SDK-native type — that's implementation detail that stops at the provider boundary.
The normalized status strings are equally important. DocuSign has its own status vocabulary, and if I ever add another provider, theirs will differ too. Normalizing at the interface level means my business logic has exactly one vocabulary to deal with.
Implementing the DocuSign Provider
With the interface defined, I implemented the DocuSign-specific class. This is the only place in the codebase where DocuSign's SDK, authentication quirks, and API conventions actually matter.
# esignature/providers/docusign.py
import base64
from docusign_esign import (
ApiClient,
EnvelopesApi,
EnvelopeDefinition,
Document,
Signer,
SignHere,
Tabs,
Recipients,
)
from docusign_esign.client.auth import OAuthToken
from django.conf import settings
from .base import (
BaseESignatureProvider,
EnvelopeResult,
SigningRecipient,
SigningUrlResult,
)
STATUS_MAP = {
"sent": "sent",
"delivered": "sent",
"completed": "completed",
"voided": "voided",
"declined": "declined",
"created": "draft",
}
class DocuSignProvider(BaseESignatureProvider):
def __init__(self):
self._client = self._build_client()
def _build_client(self) -> ApiClient:
client = ApiClient()
client.host = settings.ESIGNATURE_DOCUSIGN_BASE_URL
client.set_default_header(
"Authorization",
f"Bearer {self._get_access_token()}",
)
return client
def _get_access_token(self) -> str:
# In production I use JWT grant auth; simplified here for clarity
api_client = ApiClient()
api_client.host = settings.ESIGNATURE_DOCUSIGN_AUTH_URL
token: OAuthToken = api_client.request_jwt_user_token(
client_id=settings.ESIGNATURE_DOCUSIGN_CLIENT_ID,
user_id=settings.ESIGNATURE_DOCUSIGN_USER_ID,
oauth_host_name=settings.ESIGNATURE_DOCUSIGN_AUTH_HOST,
private_key_bytes=settings.ESIGNATURE_DOCUSIGN_PRIVATE_KEY.encode(),
expires_in=3600,
)
return token.access_token
def create_envelope(
self,
document_name: str,
document_bytes: bytes,
recipients: list[SigningRecipient],
subject: str = "Please sign this document",
) -> EnvelopeResult:
document = Document(
document_base64=base64.b64encode(document_bytes).decode("utf-8"),
name=document_name,
file_extension="pdf",
document_id="1",
)
signers = [
Signer(
email=r.email,
name=r.name,
recipient_id=r.recipient_id,
routing_order="1",
tabs=Tabs(
sign_here_tabs=[
SignHere(
anchor_string="/sig/",
anchor_units="pixels",
anchor_x_offset="0",
anchor_y_offset="0",
)
]
),
)
for r in recipients
]
envelope_definition = EnvelopeDefinition(
email_subject=subject,
documents=[document],
recipients=Recipients(signers=signers),
status="sent",
)
api = EnvelopesApi(self._client)
result = api.create_envelope(
account_id=settings.ESIGNATURE_DOCUSIGN_ACCOUNT_ID,
envelope_definition=envelope_definition,
)
return EnvelopeResult(
envelope_id=result.envelope_id,
status=STATUS_MAP.get(result.status, result.status),
provider="docusign",
)
def get_signing_url(
self,
envelope_id: str,
recipient: SigningRecipient,
return_url: str,
) -> SigningUrlResult:
from docusign_esign import RecipientViewRequest
view_request = RecipientViewRequest(
authentication_method="none",
client_user_id=recipient.recipient_id,
recipient_id=recipient.recipient_id,
return_url=return_url,
user_name=recipient.name,
email=recipient.email,
)
api = EnvelopesApi(self._client)
result = api.create_recipient_view(
account_id=settings.ESIGNATURE_DOCUSIGN_ACCOUNT_ID,
envelope_id=envelope_id,
recipient_view_request=view_request,
)
return SigningUrlResult(url=result.url)
def get_envelope_status(self, envelope_id: str) -> str:
api = EnvelopesApi(self._client)
result = api.get_envelope(
account_id=settings.ESIGNATURE_DOCUSIGN_ACCOUNT_ID,
envelope_id=envelope_id,
)
return STATUS_MAP.get(result.status, result.status)
A few things I want to highlight here:
- The
STATUS_MAPdictionary is the seam between DocuSign's world and my application's world. When I add another provider, it will have its own map — but the values on the right side of that map stay the same. - The
__init__calls_build_client(), which handles authentication. I deliberately kept authentication inside the provider class. It's tempting to pass an already-authenticated client in from outside, but that just moves provider-specific concerns up the stack. - I store provider credentials under clearly namespaced settings keys (
ESIGNATURE_DOCUSIGN_*). When I add a second provider, it gets its own namespace (ESIGNATURE_ANOTHERPROVIDER_*), and there's no ambiguity.
Wiring It Together: The Provider Registry
With the abstract base and at least one concrete implementation in place, I needed a way to select the right provider at runtime without littering if/elif chains across the codebase. I built a small registry module for this:
# esignature/registry.py
from django.conf import settings
from django.utils.module_loading import import_string
from .providers.base import BaseESignatureProvider
_BUILT_IN_PROVIDERS = {
"docusign": "esignature.providers.docusign.DocuSignProvider",
"mock": "esignature.providers.mock.MockESignatureProvider",
}
def get_esignature_provider() -> BaseESignatureProvider:
"""
Returns an instantiated e-signature provider based on Django settings.
Set ESIGNATURE_PROVIDER in settings to a provider key ('docusign', 'mock')
or a full dotted path to a custom class.
"""
provider_setting = getattr(settings, "ESIGNATURE_PROVIDER", "mock")
dotted_path = _BUILT_IN_PROVIDERS.get(provider_setting, provider_setting)
provider_class = import_string(dotted_path)
if not issubclass(provider_class, BaseESignatureProvider):
raise TypeError(
f"{dotted_path} must be a subclass of BaseESignatureProvider"
)
return provider_class()
And in settings.py (or environment-specific overrides):
# settings/production.py
ESIGNATURE_PROVIDER = "docusign"
ESIGNATURE_DOCUSIGN_BASE_URL = env("ESIGNATURE_DOCUSIGN_BASE_URL")
ESIGNATURE_DOCUSIGN_AUTH_URL = env("ESIGNATURE_DOCUSIGN_AUTH_URL")
ESIGNATURE_DOCUSIGN_AUTH_HOST = env("ESIGNATURE_DOCUSIGN_AUTH_HOST")
ESIGNATURE_DOCUSIGN_CLIENT_ID = env("ESIGNATURE_DOCUSIGN_CLIENT_ID")
ESIGNATURE_DOCUSIGN_USER_ID = env("ESIGNATURE_DOCUSIGN_USER_ID")
ESIGNATURE_DOCUSIGN_ACCOUNT_ID = env("ESIGNATURE_DOCUSIGN_ACCOUNT_ID")
ESIGNATURE_DOCUSIGN_PRIVATE_KEY = env("ESIGNATURE_DOCUSIGN_PRIVATE_KEY")
# settings/testing.py
ESIGNATURE_PROVIDER = "mock"
The import_string utility from Django is doing quiet but important work here — it lets me support fully-qualified class paths as provider values, so a team using a custom or third-party provider class doesn't need to fork this registry file.
Switching providers in a container environment is now a single environment variable change. That was a concrete requirement during the DocuSign integration, where I needed the orchestration to pick up the new provider variables without any code deployment.
The Mock Provider: Making Tests Sane
One of the immediate benefits I felt was in testing. Before the abstraction, unit tests for document-related workflows would either hit DocuSign's sandbox API (slow, flaky, rate-limited) or require elaborate monkeypatching. Now, I have a MockESignatureProvider that's a first-class citizen:
# esignature/providers/mock.py
import uuid
from .base import BaseESignatureProvider, EnvelopeResult, SigningRecipient, SigningUrlResult
class MockESignatureProvider(BaseESignatureProvider):
"""
In-memory provider for use in tests and local development.
Stores envelope state in a class-level dict so assertions are easy.
"""
_envelopes: dict[str, dict] = {}
def create_envelope(
self,
document_name: str,
document_bytes: bytes,
recipients: list[SigningRecipient],
subject: str = "Please sign this document",
) -> EnvelopeResult:
envelope_id = str(uuid.uuid4())
self._envelopes[envelope_id] = {
"document_name": document_name,
"recipients": recipients,
"subject": subject,
"status": "sent",
}
return EnvelopeResult(
envelope_id=envelope_id,
status="sent",
provider="mock",
)
def get_signing_url(
self,
envelope_id: str,
recipient: SigningRecipient,
return_url: str,
) -> SigningUrlResult:
return SigningUrlResult(
url=f"https://mock-signing.example.com/sign/{envelope_id}/{recipient.recipient_id}"
)
def get_envelope_status(self, envelope_id: str) -> str:
envelope = self._envelopes.get(envelope_id)
if not envelope:
raise ValueError(f"Envelope {envelope_id} not found in mock store")
return envelope["status"]
# Test helper — not part of the interface
def simulate_completion(self, envelope_id: str) -> None:
if envelope_id in self._envelopes:
self._envelopes[envelope_id]["status"] = "completed"
A Django test using this looks straightforward:
# tests/test_document_service.py
from django.test import TestCase, override_settings
from esignature.registry import get_esignature_provider
from esignature.providers.base import SigningRecipient
@override_settings(ESIGNATURE_PROVIDER="mock")
class DocumentSigningServiceTest(TestCase):
def test_envelope_created_and_status_tracked(self):
provider = get_esignature_provider()
recipient = SigningRecipient(
name="Jane Smith",
email="jane@example.com",
)
result = provider.create_envelope(
document_name="Contract.pdf",
document_bytes=b"%PDF-1.4 fake pdf content",
recipients=[recipient],
)
self.assertEqual(result.status, "sent")
self.assertEqual(result.provider, "mock")
status = provider.get_envelope_status(result.envelope_id)
self.assertEqual(status, "sent")
# Simulate a webhook callback completing the envelope
provider.simulate_completion(result.envelope_id)
status = provider.get_envelope_status(result.envelope_id)
self.assertEqual(status, "completed")
No mocking library magic, no patch() gymnastics. The test reads exactly like the business logic it's covering.
Practical Tips From the Trenches
Don't leak SDK types through your interface. The moment a return type from your abstract method references docusign_esign.SomeModel, you've coupled everything that imports from the interface to the DocuSign SDK. Use your own dataclasses or TypedDicts.
Normalize error handling too. I didn't show this above for brevity, but I also defined a small ESignatureError exception hierarchy in base.py. Each provider catches its own SDK exceptions and re-raises as ESignatureError subclasses. That way my service layer only ever handles one exception family.
Be careful with provider instantiation cost. DocuSign's JWT authentication involves an HTTP round-trip. If get_esignature_provider() is called per-request, you're paying that cost constantly. In my setup, I wrapped the result in a Django-scoped cache or used lazy instantiation at the module level, depending on the view's threading model.
Container orchestration becomes easier. When I rolled out DocuSign, the only change to my docker-compose and deployment manifests was adding the new ESIGNATURE_DOCUSIGN_* environment variables and changing ESIGNATURE_PROVIDER. The application code that orchestrates document workflows didn't get a single commit.
Write the second provider before you think you need it. The mock provider isn't just for tests — building it forced me to verify that my interface was actually generic enough. If I'd written the interface and only implemented DocuSign, I might have left implicit assumptions baked in that would have broken the moment a real second provider arrived.
Conclusion
The provider pattern isn't a new idea, but applying it deliberately to third-party service integrations in Django pays dividends fast. Here's what I came away with:
- Define the interface first, derived from what your application actually needs — not from what the third-party API offers.
- Normalize at the boundary: statuses, error types, and return shapes should all be your vocabulary, translated from the provider's by the time they leave the implementation class.
-
Use Django's settings and
import_stringto make provider selection a deployment-time decision, not a code change. - The mock provider is a first-class deliverable, not an afterthought. It makes your tests fast, isolated, and honest about what the interface contract actually is.
When the next e-signature provider comes up in a requirements meeting, I won't dread the integration work — I'll just implement the three abstract methods, add a settings key, and everything else keeps working.
Top comments (0)