DEV Community

INIYAN K
INIYAN K

Posted on

Prototype & Factory Method Design Patterns in Java - ClauseGuard

Introduction

ClauseGuard lets legal teams upload a contract — PDF or DOCX — and get back something far more useful than a raw document: individually segmented clauses, each classified by type, scored for risk, and surfaced on a dashboard the moment analysis finishes.

Under the hood, three classic design patterns quietly do most of the structural work: the Adapter pattern lets two completely different document-parsing libraries (PDFBox and Apache POI) speak one common language, the Factory pattern decides which of those adapters to build in the first place, and the Observer pattern broadcasts the finished analysis to the dashboard, the audit log, and the alerting system all at once. This post walks through each one, why we needed it, and how they fit together into a single pipeline.

TL;DR — Factory decides which extractor to build based on file type, Adapter makes PDFBox and Apache POI speak the same interface, and Observer broadcasts the finished analysis to everyone who needs to react to it. Together they let us add a new file format or a new analysis listener without touching the core pipeline.

📑 Table of Contents

  1. The Adapter Pattern — Unifying Document Text Extraction
  2. The Factory Pattern — Choosing the Right Extractor
  3. The Observer Pattern — Broadcasting Analysis Results
  4. How the Three Patterns Work Together
  5. Performance Metrics
  6. Conclusion

1. The Adapter Pattern — Unifying Document Text Extraction

The Problem

A contract can arrive as a PDF or a DOCX, and the two libraries ClauseGuard uses to read them — Apache PDFBox and Apache POI — don't agree on much. PDFBox wants a PDDocument loaded from a file stream and hands text back through a PDFTextStripper. POI wants an XWPFDocument and hands text back paragraph by paragraph. If the Clause Segmentation Engine had to know the difference, every file-type quirk would leak straight into the core pipeline.

The Solution

The Adapter pattern wraps each library behind one common interface, TextExtractor, so the rest of the system only ever calls .extract(file) — regardless of what's happening underneath.

class TextExtractor:
    def extract(self, file):
        pass

class PDFExtractorAdapter(TextExtractor):
    def __init__(self, pdf_box_lib):
        self.pdf_box_lib = pdf_box_lib   # the mismatched third-party API

    def extract(self, file):
        document = self.pdf_box_lib.load(file)
        return self.pdf_box_lib.strip_text(document)

class DOCXExtractorAdapter(TextExtractor):
    def __init__(self, poi_lib):
        self.poi_lib = poi_lib

    def extract(self, file):
        document = self.poi_lib.open_xwpf(file)
        return "\n".join(p.get_text() for p in document.get_paragraphs())
Enter fullscreen mode Exit fullscreen mode

ContractIngestionService (the client) only ever depends on TextExtractor. It has no idea PDDocument or XWPFDocument even exist — that detail is hidden entirely inside the adapter.

Diagram: Target/Adapter on top (TextExtractor), Client (ContractIngestionService) and Adaptees below — PDFExtractorAdapter and DOCXExtractorAdapter translate PDFBox and POI into the app's own interface.

Benefits

  • 🔌 Swap or upgrade a parsing library without changing ContractIngestionService
  • 🧩 Library-specific quirks (PDFBox's stripper config, POI's paragraph model) stay contained in one adapter class instead of leaking everywhere
  • 🧪 Makes the ingestion layer easy to unit-test with mock extractors, without needing a real PDF or DOCX file on disk

💡 Key takeaway: The adapter is a translator, not a decision-maker. It doesn't decide whether to extract text — it just makes sure the extraction happens the same way no matter which library is doing the work underneath.

2. The Factory Pattern — Choosing the Right Extractor

The Problem

Even with a clean TextExtractor interface, something still has to decide which concrete adapter to instantiate — PDFExtractorAdapter or DOCXExtractorAdapter — based on the uploaded file's extension. Scattering that decision (if filename.endswith(".pdf"): ... elif filename.endswith(".docx"): ...) across upload handlers, retry logic, and re-processing jobs is exactly the kind of duplication design patterns exist to prevent.

The Solution

TextExtractorFactory centralizes that decision in one place.

class TextExtractorFactory:
    @staticmethod
    def create_extractor(filename):
        if filename.endswith(".pdf"):
            return PDFExtractorAdapter(PDFBoxLib())
        elif filename.endswith(".docx"):
            return DOCXExtractorAdapter(ApachePOILib())
        else:
            raise ValueError(f"Unsupported file type: {filename}")
Enter fullscreen mode Exit fullscreen mode

ContractIngestionService never touches PDFExtractorAdapter or DOCXExtractorAdapter by name — it just asks the factory for an extractor and uses it:

# Client code — no idea which class it's actually talking to
extractor = TextExtractorFactory.create_extractor(contract.filename)
raw_text = extractor.extract(contract.file)
Enter fullscreen mode Exit fullscreen mode

Diagram: TextExtractorFactory decides which concrete TextExtractor to build — PDFExtractorAdapter or DOCXExtractorAdapter — based on the uploaded file's extension.

Benefits

  • 🎯 One place to update when a new file type is added (e.g., plain .txt support) — not a dozen scattered conditionals
  • 🧱 ContractIngestionService depends only on the TextExtractor interface, never on concrete classes
  • 🔀 Makes it trivial to reject unsupported formats early, with a single, consistent error path

💡 Key takeaway: The factory is the only piece of code in the entire app that's allowed to say new PDFExtractorAdapter(). Everyone else just asks for "an extractor for this file" and trusts the factory to hand back something that works.

3. The Observer Pattern — Broadcasting Analysis Results

The Problem

When a contract finishes analysis — classified, risk-scored, and categorized — several things need to happen at once: the audit log needs a permanent record, the risk dashboard needs to update its flagged-clause list, and if a clause came back High Risk, an alert needs to go out to configured administrators. Hard-coding all of that inside the analysis engine would tightly couple scoring logic to logging logic, dashboard logic, and notification logic — a maintenance nightmare the moment we add a new kind of listener.

The Solution

The Observer pattern decouples the AnalysisEngine (the thing that changes) from everything that needs to react to that change. AnalysisEngine doesn't know or care what AuditLogger or AlertNotifier actually does — it just calls update() on whoever is registered.

class Observer:
    def update(self, analysis_result):
        pass

class AuditLogger(Observer):
    def update(self, analysis_result):
        print("AuditLog: recorded analysis for", analysis_result.contract_id)

class RiskDashboard(Observer):
    def update(self, analysis_result):
        print("Dashboard: refreshing flagged clauses for", analysis_result.contract_id)

class AlertNotifier(Observer):
    def update(self, analysis_result):
        if analysis_result.has_high_risk_clause():
            print("Alert: High-risk clause detected in", analysis_result.contract_id)

class AnalysisEngine:
    def __init__(self):
        self.observers = []

    def add_observer(self, observer):
        self.observers.append(observer)

    def notify(self, analysis_result):
        for observer in self.observers:
            observer.update(analysis_result)
Enter fullscreen mode Exit fullscreen mode

Diagram: Subject/Observer interfaces at top, with AnalysisEngine as the ConcreteSubject and AuditLogger/RiskDashboard/AlertNotifier as ConcreteObservers.

How It Works in ClauseGuard

AnalysisEngine"Analysis complete → Contract #482, 3 clauses flagged High Risk"

Observers receive that notification and each reacts in its own way:

  • 📝 AuditLogger writes an immutable log entry before anything else happens
  • 🖥️ RiskDashboard refreshes the live flagged-clause view
  • 🚨 AlertNotifier fires an email/webhook to configured admins if a High Risk clause was found
  • 📊 ReportService queues the result for the next scheduled Contract Summary export

Adding a new kind of listener — say, a Slack notifier or a compliance-export trigger — never requires touching AnalysisEngine. We just implement Observer and call add_observer().

Benefits

  • 🔗 Loose coupling between the analysis engine and everything watching it
  • ➕ New observers can be added without modifying existing code (open/closed principle)
  • 🔄 Every observer gets the result at the same time, so the audit log is never written after a risky clause has already been shown to a user

💡 Key takeaway: AnalysisEngine never says the word "email," "dashboard," or "log." It just says "analysis finished, here's the result" — and lets each observer decide what that means for it.

How the Three Patterns Work Together

These aren't three isolated exercises — they form a pipeline:

  1. Factory decides which extractor to build — TextExtractorFactory.create_extractor("contract.pdf")
  2. Adapter makes that extractor speak the app's common language — PDFExtractorAdapter implements TextExtractor
  3. Observer decides who reacts once analysis is done — AnalysisEngine.notify() fires to every registered listener

The chain, step by step: a contract is uploaded → TextExtractorFactory inspects the filename and hands back the right adapter → the adapter extracts raw text through a single .extract() call regardless of the underlying library → segmentation, classification, and risk scoring run on that text → AnalysisEngine.notify() fires once the result is ready → AuditLogger, RiskDashboard, and AlertNotifier all react independently, in any order, with zero knowledge of each other.

A contract lands in the system → the Factory picks the right tool for the file type → the Adapter makes that tool speak a language the rest of the pipeline understands → the Observer pattern broadcasts the finished result to everyone who needs to know. Three patterns, one clean chain of responsibility — no piece knows more than it needs to.

Performance Metrics of the Three Patterns

Metric Adapter Factory Observer
Scalability High High High
Maintainability Excellent Excellent Excellent
Code Reusability High High High
Flexibility Supports any current or future document-parsing library Supports adding new file formats with one new branch/class Supports multiple listener modules (audit, dashboard, alerts, reporting)
Coupling Low Low Low
Extensibility Easy to add a new format-specific adapter Easy to add new extractor types Easy to add new observers without touching AnalysisEngine

Conclusion

None of these patterns exist for their own sake — each one solves a concrete problem ClauseGuard actually has: dealing with two incompatible document-parsing libraries (Adapter), centralizing which extractor gets built for a given file type (Factory), and keeping the audit log, dashboard, and alerting system in sync the instant an analysis finishes (Observer). Used together, they let us support a new file format, a new alerting channel, or a new kind of downstream listener without rewriting the core ingestion-to-risk-scoring pipeline — which is really the whole point of a design pattern: not cleverness for its own sake, but code that's easy to extend and hard to break.

Top comments (0)