Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 16

Designing with Interfaces

Recommended resources

Introduction

In our previous lesson on Clean Architecture, we established the crucial Dependency Rule: all source code dependencies must point inwards, from concrete details toward abstract policies. This rule is the bedrock of maintainable systems, but it raises a practical question: how do we actually enforce it at the boundaries between layers?

This lesson will answer that question directly. We will explore the specific tools and techniques for defining these boundaries using abstract interfaces. Your goal is to become proficient at creating these contracts in both Rust and Python, the two languages you work with. In Rust, we will master the use of traits. In Python, we will focus on the modern approach using typing.Protocol, while also understanding its relationship to older patterns like Abstract Base Classes (ABCs).

Think of this lesson as moving from the "what" and "why" of architectural patterns to the "how" of their implementation. These abstract interfaces are the seams that allow you to swap databases, web frameworks, or even computer vision models without destabilizing your application's core logic.


The Anatomy of an Architectural Boundary

At its core, an abstract interface is a contract. It specifies what a component can do but says nothing about how it does it. This is the practical application of the Dependency Inversion Principle (DIP). Instead of high-level policy code depending on low-level implementation details, both depend on this shared, abstract contract.

Consider the following diagram, which illustrates this concept within the context of Hexagonal (Ports and Adapters) Architecture.

This diagram shows how different parts of a system interact through abstract boundaries. The `domain` defines its data (`Stuff`). The `ports` layer defines a contract, the `Sender` trait. The `application` logic uses this abstract `Sender`. Finally, the `adapters` layer provides a concrete implementation for the `Sender` trait, and the `main()` function wires everything together.

The key lies in the direction of the arrows and the dependencies:

  1. The Port (the Sender trait in the diagram) is defined by the needs of the application's core. It's an abstraction.
  2. The Application logic depends only on this abstract Port. It is completely ignorant of any concrete implementation.
  3. The Adapter (the concrete implementation) depends on and implements the Port's contract.
  4. The source code dependency arrow points inward—from the concrete adapter to the abstract port defined by the core.

This structure is what makes an architecture "pluggable." The application can work with any adapter, as long as that adapter fulfills the Sender contract. Let's now see how to build these contracts in Rust and Python.


Defining Boundaries in Rust with traits

In Rust, the trait system is the idiomatic and compile-time enforced mechanism for creating abstract interfaces. It's a powerful tool for ensuring your architectural rules are not just conventions, but guarantees checked by the compiler.

The following resource provides a superb, step-by-step guide to applying the Dependency Inversion Principle in Rust.

SOLID Principles in Rust: A Practical Guide - 04 | 40tude

This guide from 40tude provides a clear and practical walkthrough of using traits to create architectural boundaries, culminating in a full Hexagonal Architecture example.

Please read through the entire "Dependency Inversion Principle (DIP)" section. It's structured as a narrative, evolving from a problematic design to a clean one. Start with The Principle and The Problem. Pay close attention to the code in "The Problem: Direct Dependencies" and the author's comments. Note how OrderService is tightly coupled to email::Sender. Next, read The Solution. This is the most critical part. See how a Sender trait is introduced in the domain module. The OrderService becomes generic over this trait, and the email module now implements the trait. Notice the reversal of the use statements—this is the "inversion" in action. Read Testing Becomes Trivial. This demonstrates a key benefit of this pattern: the ability to test your business logic in complete isolation by providing a MockSender. Finally, read the section on Hexagonal Architecture. This scales the simple Sender example to a more realistic application with multiple ports (OrderRepository, PaymentGateway). This code block is long but rewarding, as it shows how these individual boundaries come together to form a robust architecture. Pay special attention to the comments explaining the role of the domain, ports, application, and adapters modules. To conclude, quickly review the Rust-Specific Notes, which cover practical considerations like generics vs. trait objects and how crate organization enforces dependency direction.

The progression in that article is key: you start with a single, tightly-coupled dependency. You "invert" it by introducing a trait as a contract. This immediately improves testability and flexibility. You then scale this pattern by defining multiple traits (ports) for every interaction your application core has with the outside world (database, payment gateways, etc.), arriving at a complete Hexagonal Architecture.

The Rust compiler becomes your architectural guardian. If your domain crate tries to use a type from your postgres_adapter crate, the compiler will stop you, enforcing the Dependency Rule at build time.


Defining Boundaries in Python with Protocols

Python's dynamic nature offers a different, but equally powerful, approach to defining abstract boundaries. While it has formal Abstract Base Classes (ABCs), the modern and more "Pythonic" way to define architectural contracts is through structural subtyping, using typing.Protocol.

The Spectrum of Python Interfaces

  1. Duck Typing (Informal): The traditional approach. If an object has a .quack() method, you can treat it like a duck. The contract is implicit and relies on convention and documentation. This is flexible but offers no static safety.
  2. ABCs (Nominal Subtyping): A more formal approach using abc.ABC. Classes must explicitly inherit from the ABC and implement its abstract methods. This is checked at instantiation time and feels similar to interfaces in languages like Java or C#. However, forcing inheritance can lead to rigid hierarchies and is often considered less flexible than desired.
  3. Protocols (Structural Subtyping): Introduced in PEP 544, typing.Protocol combines the flexibility of duck typing with the safety of static type checking. A class conforms to a protocol if it simply has the right methods and signatures—no inheritance required. This is often called "static duck typing."

For defining architectural boundaries, Protocol is generally the superior choice. It allows your adapters to conform to the contracts defined by your application core without being forced into a specific inheritance tree.

The following video from ArjanCodes provides a fantastic, practical demonstration of building these boundaries with Protocol.

Stop Mixing FastAPI with Business Logic: Fix It with Ports & Adapters

This video refactors a FastAPI application that mixes business logic and framework code into a clean Ports and Adapters architecture, using Protocol to define the boundary.

First, watch the introduction to Ports and Adapters to understand the three layers: domain, ports, and adapters. Next, see how the domain models and errors are defined independently of any framework in the segment from decoupling the domain. Pay close attention to how the "port" is defined using typing.Protocol in the clip from defining a port. This InventoryPort is the abstract contract. See how the business logic is rewritten to depend only on this abstract port in the section from rewriting the use case. Notice the complete absence of any SQLAlchemy or FastAPI-specific code. Finally, watch how the concrete "adapter" is built to implement the port in the segment on the SQLAlchemy adapter. This section shows the implementation of the contract using a specific technology. The final integration into the FastAPI endpoint (patching things up) clearly shows where the wiring of concrete to abstract happens.

Protocols and the Interface Segregation Principle

A powerful feature of protocols is how naturally they support the Interface Segregation Principle (ISP). Since a class doesn't need to inherit, different parts of your system can define their own small, specific protocols for the same underlying objects.

A client (e.g., a use case) should not be forced to depend on methods it does not use. Protocols make this easy to achieve.

This next video, also from ArjanCodes, masterfully demonstrates this concept.

Protocol Or ABC In Python - When to Use Which One?

This video compares ABCs and Protocols. This specific section shows how Protocols excel at creating fine-grained, client-specific interfaces, which is a hallmark of good architectural design.

Please watch the section from "a big difference between protocols and ABCs". The example shows a single, large Device interface being split into two smaller, more focused protocols: A Device protocol for the main IoT service, needing methods like connect and send_message. A DiagnosticSource protocol for the diagnostics function, which only needs a status_update method. The key insight is that the protocols are defined by the clients that use them, leading to much looser coupling.

This is a profound architectural concept. Instead of one large, monolithic interface, you create several small, role-based interfaces. This ensures that the diagnostics module remains completely independent of the iot_service module, as they each depend on their own, minimal contract.


Application to a Computer Vision Pipeline

Let's ground this in your domain of industrial computer vision. Imagine a pipeline that captures frames, detects defects, and logs them. Using abstract interfaces, we can design a highly maintainable system.

1. Define the Ports (Contracts):

In a ports.py (or ports.rs) file, you would define the contracts your application core needs.

  • In Python (using Protocol):

    from typing import Protocol, List
    from .domain_models import ImageFrame, Detection
    
    class FrameSource(Protocol):
        def get_frame(self) -> ImageFrame: ...
    
    class DefectDetector(Protocol):
        def detect(self, frame: ImageFrame) -> List[Detection]: ...
    
    class DetectionLogger(Protocol):
        def log(self, detections: List[Detection]) -> None: ...
    
  • In Rust (using trait):

    use crate::domain_models::{ImageFrame, Detection, Result};
    
    pub trait FrameSource {
        fn get_frame(&mut self) -> Result<ImageFrame>;
    }
    
    pub trait DefectDetector {
        fn detect(&self, frame: &ImageFrame) -> Result<Vec<Detection>>;
    }
    
    pub trait DetectionLogger {
        fn log(&self, detections: &[Detection]) -> Result<()>;
    }
    

2. Implement the Adapters:

Your adapters would provide concrete implementations for these ports.

  • RtspFrameSource and FileFrameSource could both implement FrameSource.
  • YoloV8Detector and TensorRTDetector could both implement DefectDetector.
  • PostgresLogger and CsvLogger could both implement DetectionLogger.

3. Orchestrate in the Application Core:

Your main application logic would be written solely against the abstract ports, completely unaware of YOLO, TensorRT, or PostgreSQL.




# application_logic.py
def run_inspection_cycle(
    source: FrameSource, 
    detector: DefectDetector, 
    logger: DetectionLogger
):
    frame = source.get_frame()
    detections = detector.detect(frame)
    if detections:
        logger.log(detections)

This design allows you to swap out the camera source, upgrade the detection model, or change the logging destination by simply providing a different adapter at startup, without ever touching the core run_inspection_cycle function.


Conclusion

You have now learned the fundamental techniques for creating the boundaries that are essential to clean, maintainable architectures. These abstract interfaces are not just a theoretical concept; they are the practical tools that enable pluggability, testability, and long-term evolution of your software.

Key Takeaways:

  • Abstract interfaces act as contracts that enforce the Dependency Rule, forming the boundaries between architectural layers.
  • In Rust, traits are the primary tool for defining these contracts, providing strong, compile-time guarantees.
  • In Python, typing.Protocol enables "static duck typing," offering a flexible yet type-safe way to define boundaries through structural subtyping, which is generally preferred over the nominal subtyping of ABCs for this purpose.
  • Well-designed interfaces are small and client-specific, adhering to the Interface Segregation Principle.

In our next lesson, we will put all of this together. We will undertake a design exercise to apply a chosen structural pattern (like Hexagonal Architecture) to a complete image processing pipeline, using the interface definition skills you've developed today to architect a robust and flexible solution.