DEV Community

Alair Joao Tavares
Alair Joao Tavares

Posted on • Originally published at activi.dev

Decoupling External Services: Implementing the Provider Pattern for E-Signature Integration in Django

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:

  1. Create an envelope (a signable document package)
  2. Get the signing URL for a recipient
  3. 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'.
        """
        ...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

A few things I want to highlight here:

  • The STATUS_MAP dictionary 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()
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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_string to 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)