Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Credential Service - EC Issuer

Credential service that issues and signs Open Badges 3.0 and European Learner Model (ELM) credentials.

Python CI/CD

This service orchestrates and integrates with existing issuer services, working alongside them.

Features

  • Issue Credentials: Create and sign verifiable credentials
  • Multiple Formats: Support for Open Badges 3.0 and European Learner Model
  • Revocation: Revoke credentials with reason tracking
  • Expiration: Automatic status updates for expired credentials
  • Digital Signatures: Integration with signing service

Technology Stack

  • Flask: Lightweight HTTP server
  • UV, Ruff, Basedpyright: Runtime, dependency manager, linter, typechecker
  • Pytest: Run tests
  • Docker or Podman: Building containers, running (mocks of) external services
  • Github Actions: Test, Lint, Typecheck, Build and push images

Goals

  • Simple, pragmatic REST API for credential administration: issue, aka create-offers
  • Authorization of issuing actor (may they issue achievement to user?)
  • Validation of data model and prerequisites
  • Orchestrating the credential creation workflow
  • Publishing events for downstream processing
  • Signing and delivering OpenBadges 3.0 credentials to wallets via OID4VCI
  • Signing and delivering ELM credentials as downloadable credential files

Out of scope? Unknown if still relevant

  • Batching operations for efficient signing
  • Embedding resources (images, evidence) or their resource-integrity hashes in credentials
  • Multitenancy, depends on our criteria around signing -on-behalf-of-, but could be implemented with a dedicated issuer-agent issuer per tenant

Non Goals

  • Revocation. Part of ec-status.
  • BadgeClass (template, achievements) management. Part of ec-achievement.
  • Authorization logic. Part of ec-authorization.
  • Key management. Part of ec-key.
  • Trust anchoring. Part of ec-trust and/or ec-key.
  • Notifications and messaging (emails, push notifications etc). Part of ec-notifications.
  • Storage of claims, users, credentials. Part of resp ec-achievement, ec-user, ec-award(locker).

Getting Started

Prerequisites

  • Python 3.10+
  • UV
  • Podman (for running tests that require services)

Environment Variables

SERVER_HOST=0.0.0.0
SERVER_PORT=8080
SSI_AGENT_URL="https://issuer.example.com"
SSI_AGENT_NONCE_ENDPOINT="https://issuer.example.com/openid4vci/nonce"
SSI_AGENT_CREDENTIAL_ENDPOINT="https://issuer.example.com/openid4vci/credential"

Running the Service

just develop

The service will start on http://localhost:8080

Running Tests

just test        # Run unit and integration tests

Note: Integration and e2e tests require external services to be running. e2e tests require the service to be running as well.

To start services for testing:

just dependencies

The CI pipeline provides required services via GitHub Actions. Locally, you must start services before running tests that require them. Test fixtures never start or stop services — they only return connection strings.

Development

This project uses Just for common development tasks and has a GitHub Actions CI/CD pipeline.

Testing

This project follows the Testing Pyramid approach with three test levels:

  • Unit tests: Fast, isolated tests of individual components using test doubles
  • Integration tests: Tests that verify interactions between components, may require external services
  • End-to-end tests: Tests that verify complete system behavior through the public API

Available Commands

Run just --list for all commands, including starting services, linting, running tests, building and so on.

Deployment

Version

We use semantic versioning.

Bump version with uv version --bump, see uv version --help for all options. On git branch main, add a git tag with the same version, with git tag --sign "v$(uv version --short)". Add a short message with the main change. Push main branch to github. The CI/CD will build a container after all checks and tests pass.

Deploying this container is not automated yet, due to limitations of the hosting environment (Surf Development Platform, SDP). Management of this platform, and therefore deployment, is done via a separate infrastructure repository.

License

MIT

Features

User stories taken from SURF Confluence. The Confluence versions are leading and we should update, remove, change this document accordingly.

These user stories describe the whole platform of EduCredentials, not just the issuer part. We only show the stories that are relevant.

For each such story, one or more features for the issuer are derived.

Wallet

As a learner, I want to store the credential in my personal mobile wallet.

Refer to dedicated Import In Wallet for technical details.

Import in wallet

As a student, when I have a badge in my backpack, and I have the unime app, then I want to be able to import the badge into this app as Verifiable Credential. So that I am the true owner of my badge credential. And so that I can use it to prove that I have met the requirements of the achievement the badge represents.

Features:

  • OpenID for Verifiable Credential Issuance (OID4VCI) standards authorization code flow with a mobile wallet
    • Credential Issuer Metadata (ADR003: needs re-implementation with ssi-agent as peer service)
    • Credential Offer creation and delivery to user as QR code (ADR003: needs re-implementation with ssi-agent as peer service)
    • OpenID connect authentication flow started from wallet (ADR003: needs re-implementation with ssi-agent as peer service)
    • Credential Request with authorization token and proof of possession (ADR003: needs re-implementation with ssi-agent as peer service)
    • Credential Response with signed Verifiable Credential in Open Badges 3.0 format (ADR003: needs re-implementation with ssi-agent as peer service)
    • Deferred Credential response (ADR003: needs re-implementation with ssi-agent as peer service)

Edge Cases

As student Anna, when I have a badge in my backpack, and I import that in my wallet, then the device that has this wallet will require me to authenticate again. And when I authenticate there as Anna as well, I receive the credential in my wallet.

As student Anna, when I have a badge in my backpack, and Mallory scans this QR code, then the device that has his wallet will require him to authenticate again. And when he authenticate there as Mallory, he will receive an error instead of the wallet. Or when he fails to authenticate there, the flow stops there and no request is made at all.

  • In addition to the above-mentioned OID4VCI flow, the authorization token is checked to determine if the person logged in on their phone is the person that was logged in on backpack.
  • Determine if we, the EC-issuer, handle the authorization, or if we defer it to the upstream issuer.
  • Determine what attributes to match equivalence on - considering federative nature of eduids: the uuid for Anna-in-backpack may not be the uuid for Anna-on-the-phone.
  • Consider if an authentication proxy is needed to handle the oidc flow around surfconext and eduid.

Alternative wallets

As a student, when I have a badge in my backpack, and I do not have, nor want to use the unime app, then I can choose to use Paradyme or Sphereon app. Then I will be able to import the badge into this app as Verifiable Credential, but without guarantee that the app will be able to import, show, and verify the badge. So that I am the true owner of my badge credential in an app of my choice.

Features:

  • Compliance with DIIPv4 standards
    • Credential format W3C VCDM 2.0 (20 March 2025) and SD-JWT VC (draft 08)
    • Signature scheme SD-JWT as specified in VC-JOSE-COSE (20 March 2025) and SD-JWT VC (draft 08)
    • Signature algorithm ES256 (RFC 7518 May 2015)
    • Identifying Issuers, Holders, and Verifiers did:jwk (Commit 8137ac4, Apr 14 2022) and did:web (31 July 2024)
    • Issuance protocol OpenID for Verifiable Credentials Issuance (OID4VCI) (Draft 15)
    • Presentation protocol OpenID for Verifiable Presentations (OID4VP) (Draft 28)
    • Revocation mechanism IETF Token Status List (Draft 10, 2025-04-16)

ELM and Europass

As a student, when I have a badge in my backpack, and I want to download a ELM (Europass) version of the badge, then I want to be able to upload this file in the euro-pass environment so that this environment can verify the badge and show that the verification is valid.

Features:

  • Download signed Verifiable Credential file in ELM format.
  • File can be uploaded in euro-pass environment and shows as partially verified. Our eSeal signature is not valid and is allowed to show as invalid in the euro-pass environment.

Institution

As an institution, I want to hand a badge to a user, so that I can provide digital versions of certificates, diplomas and other achievements.

Such an institution is now an issuer.

ELM and OBV3 compliance

As an institution, when I issue a badge to a user, then I want to be able to provide whether this badge will be available for importing in the unime app, and/or in the euro-pass environment, so that we can provide the appropriate attributes to comply with the standards for the unime app and/or euro-pass.

Features:

  • Add an endpoint where services (e.g. issuance portal) can provide an achievement and see if this complies with OBV3 and/or ELM standards.

Edit existing achievements

As an issuer, I want to edit or archive an achievement when no credentials are issued of this achievement.

TODO: Note, this seems a non-story. As this is not something the issuer wants but merely describes the current implementation.

Digital credentials

As an issuer, I want to issue a digital credential to learners who are entitled to receive it.

Revocation

As an issuer, I want to be able to revoke credentials, so that they cannot be verified any more.

TODO: research if, and how an IETF-TOKEN-STATUS-LIST can be implemented as separate microservice from the issuance. research potential candidates for such a status list service.

Status log

As an issuer, I want to see the status and history of the issuing and revoking of a credential.

TODO: Define the statuses of a credential TODO: Define what events are needed for this “history”

Usage Statistics

As an issuer, I want to see statistics about the credentials that have been issued from my organisation.

TODO: Define KPIs for issuers and consequently the events that need to be stored for this.

Import in Wallet

Technical details for the Import In Wallet Feature.

sequenceDiagram
    autonumber
    actor EndUser
    participant Wallet

    box ec-issuer
      participant RecipientPortal
      participant ec-issuer
      participant ec-authentication
      participant ec-access-control
      participant ec-award
      participant ec-notification
    end

    box ssi-agent
      participant oid4vci-agent
    end

    EndUser->>RecipientPortal: Import in Wallet (AwardId)
    RecipientPortal->>ec-issuer: Create Credential and Offer(AwardId)
    ec-issuer->>ec-access-control: May import(AwardId)
    ec-access-control->>ec-issuer: Yes
    ec-issuer->>ec-award: Get Award(AwardId)
    ec-award->>ec-issuer: Award
    ec-issuer->>oid4vci-agent: Create Offer(Award)
    oid4vci-agent->>ec-issuer: Offer
    ec-issuer->>ec-notification: Publish OfferCreated(AwardId, Offer) event
    ec-issuer->>RecipientPortal: Offer
    RecipientPortal->>EndUser: Offer as QR

    rect rgba(0, 0, 255, .05)
        EndUser->>Wallet: Scan Offer QR
        note Right of Wallet: OID4VCI Authorization Code flow<br/>with deferred Credential endpoint

        Wallet->>oid4vci-agent: Obtain Credential Issuer Metadata
        Wallet->>ec-authentication: Authentication Request (OfferId)
        ec-authentication->>Wallet: Authentication Response (code)
        Wallet->>ec-authentication: Token Request (code)
        ec-authentication->>Wallet: Token Response (AccessToken, State<OfferId>)
        Wallet->>oid4vci-agent: Credential Request (AccessToken, Proofs)
        oid4vci-agent->>oid4vci-agent: Verify and Process Request
        oid4vci-agent->>Wallet: Credential Response (TransactionId)
    end

    oid4vci-agent->>ec-issuer: CredentialSigned event
    ec-issuer->>ec-notification: Publish CredentialIssued event

    rect rgba(0, 0, 255, .05)
      Wallet->>oid4vci-agent: Deferred Credential (TransactionId)
      oid4vci-agent->>Wallet: Verifiable Credential
      Wallet->>EndUser: Success(Verifiable Credential)
    end

In this diagram, the following actions take place:

  1. EndUser requests to import award in wallet via RecipientPortal
  2. RecipientPortal creates credential and offer for the award on ec-issuer
  3. ec-issuer checks permission on Access Control
  4. Access Control returns Yes when permission is allowed
  5. ec-issuer gets award from ec-award
  6. ec-award returns award to ec-issuer
  7. ec-issuer creates offer on oid4vci-agent
  8. oid4vci-agent returns offer to ec-issuer
  9. ec-issuer publishes offer created event on Notification Service
  10. ec-issuer returns offer to RecipientPortal
  11. RecipientPortal presents offer as QR code to EndUser
  12. EndUser scans QR code with Wallet
  13. Wallet obtains credential issuer metadata from oid4vci-agent
  14. Wallet sends authentication request to ec-authentication
  15. ec-authentication returns code to Wallet
  16. Wallet requests token with code from ec-authentication
  17. ec-authentication returns access token and state to Wallet
  18. Wallet sends credential request with access token and proofs to oid4vci-agent
  19. oid4vci-agent verifies and processes the request
  20. oid4vci-agent sends credential response with transaction ID to Wallet
  21. oid4vci-agent sends CredentialSigned event to ec-issuer
  22. ec-issuer publishes CredentialIssued event on Notification Service
  23. Wallet requests deferred credential from oid4vci-agent
  24. oid4vci-agent sends verifiable credential to Wallet
  25. Wallet notifies EndUser of success with verifiable credential

Diagram adapted from Backstage docs

HTTP API

View the OpenAPI Documentation

Service Architecture: This documentation covers two separate but integrated services:

  • ec-issuer: Administrative service that manages templates, credential configurations, and creates offers. Handles business logic and orchestration.
  • ssi-agent: Handles the OpenID4VCI protocol flow, including credential issuer metadata, credential offers, and credential requests. Performs the actual credential signing and delivery.

All API endpoints are defined in the OpenAPI specification.

Configuration

The EC Issuer is configured exclusively through environment variables, following the 12-factor app methodology. All environment variables are mandatory — there are no fallbacks or magic defaults.

Loading Environment Variables

The application does not read .env files directly. Use your preferred tool to load environment variables:

The .env.example file in the repository root provides a template. Copy it to .env (which is gitignored) and modify as needed.

Environment Variables

VariableDescription
SERVER_HOSTHost address to bind the server to
SERVER_PORTPort to listen on
PUBLIC_URLThe URL on which the service is publicly available
SSI_AGENT_URLThe base URL of the SSI agent
SSI_AGENT_NONCE_ENDPOINTFull URL to the SSI agent’s nonce endpoint
SSI_AGENT_CREDENTIAL_ENDPOINTFull URL to the SSI agent’s credential endpoint
POSTGRES_CONNECTION_STRINGPostgreSQL connection string (format: postgresql://user:password@host:port/database)
DEBUG_METRICSEnable /metrics endpoint (for development)

SSI Agent Endpoint Configuration

For standard OpenID4VCI-compliant agents, the nonce and credential endpoints are published in their Issuer Metdata.

To discover the correct endpoints from a running SSI agent, fetch its metadata:

curl -s https://agent.example.com/.well-known/openid-credential-issuer | jq -r '
  "SSI_AGENT_NONCE_ENDPOINT=$(.nonce_endpoint)\nSSI_AGENT_CREDENTIAL_ENDPOINT=$(.credential_endpoint)"
'

This outputs the exact values to use for SSI_AGENT_NONCE_ENDPOINT and SSI_AGENT_CREDENTIAL_ENDPOINT.

E.g. for ssi-agent, the endpoints are

https://ssi-agent.example.com/openid4vci/nonce
https://ssi-agent.example.com/openid4vci/credential

Architecture Documentation

Hexagonal Architecture Overview

This credential service implements Hexagonal Architecture (Ports and Adapters pattern), which provides clear separation between business logic and infrastructure concerns.

Practical Example: See ports_adapters.py for a complete working example demonstrating all key concepts with a simple jokes application. The accompanying ports_adapters_test.py shows how the ports and adapters can be leveraged to test the application on several levels.

Core Principles

1. Domain at the Center

The domain layer is the heart of the application:

  • Zero Infrastructure Dependencies: No database, HTTP, or external service dependencies
  • Pure Business Logic: Credential validation, issuance rules, revocation logic
  • Framework Agnostic: Can be used in any context (HTTP, CLI, gRPC, etc.)

2. Ports Define Interfaces

Ports define contracts between layers:

Inbound Ports (Use Cases):

  • Define interfaces for application operations (issue, get, list, revoke credentials)

Outbound Ports (Infrastructure Needs):

  • Define interfaces for external interactions (persistence, signing, event publishing)

3. Adapters Implement Ports

Adapters provide concrete implementations:

Inbound Adapters (Drivers):

  • HTTP REST API (Flask)
  • Could add: gRPC server, CLI, GraphQL, etc.

Outbound Adapters (Driven):

  • Database repository
  • Signing client
  • HTTP clients for external services
  • Configuration loader
  • Event publishers

4. Dependency Direction

Dependencies always point inward:

Adapters ──depends on──> Ports ──depends on──> Domain

Domain never depends on Ports or Adapters

Layer Details

┌─────────────────────────────────────────────────────────────┐
│                   Adapters (Inbound)                        │
│                    HTTP API (Flask)                         │
└──────────────────────┬──────────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────────┐
│                   Ports (Inbound)                           │
│    Use Cases: Issue, Get, List, Revoke, Metadata            │
└──────────────────────┬──────────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────────┐
│                    Domain Layer                             │
│   Entities: Credential, Achievement, Issuer, Metadata       │
│   Business Logic: Validation, Revocation, Expiration        │
│   Value Objects: CredentialStatus, CredentialFormat         │
└──────────────────────┬──────────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────────┐
│                   Ports (Outbound)                          │
│   Interfaces (ABC): Repository, IssuerAgent, EventPublisher │
└──────────────────────┬──────────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────────┐
│                   Adapters (Outbound)                       │
│   Database, Signing, HTTP Clients                           │
└─────────────────────────────────────────────────────────────┘

Domain Layer

Responsibilities:

  • Define domain entities (Credential, Achievement, Issuer)
  • Implement business rules (validation, revocation, expiration)
  • Define domain errors
  • Value objects (status, formats, etc.)

Ports Layer

Responsibilities:

  • Define use case interfaces (inbound ports) using Python’s ABC
  • Define infrastructure interfaces (outbound ports) using Python’s ABC
  • Implement application services (use case orchestration)

Python Implementation Notes:

  • ABC provides runtime enforcement of abstract methods
  • @abstractmethod decorator marks methods that must be implemented
  • Type checkers like basedpyright verify interface compliance statically
  • See ports_adapters.py for concrete examples using ABC

Adapters Layer

Responsibilities:

  • Implement inbound adapters (HTTP API)
  • Implement outbound adapters (database, signing, HTTP clients)
  • Handle infrastructure concerns (error translation, logging)

Benefits of This Architecture

1. Testability

Hexagonal architecture enables simple, isolated testing without complex mocks or stubs:

Unit Tests - Test domain logic in complete isolation:

# Test domain model without any infrastructure
content = "Why did the chicken cross the road. To get to the other side."
joke = Joke.from_content(content)
assert joke.title == "Why did the chicken cross the road."
assert joke.body == "To get to the other side."

Integration Tests - Test service layer with real adapters:

# Test service with real InMemoryJokesRepository (no mocks needed)
repository = InMemoryJokesRepository()
service = JokeService(repository)
jokes = service.get_jokes()
assert len(jokes) == 0  # Starts empty

End-to-End Tests - Test complete application with real persistence:

# Test actual CLI commands with file storage
# Cleanup first
shutil.rmtree("/tmp/jokes_db", ignore_errors=True)

# Test real command execution
output = subprocess.check_output([
    "python", "ports_adapters.py", "create", 
    "Test joke. Test body."
])

# Verify persistence
output = subprocess.check_output(["python", "ports_adapters.py", "list"])
assert "Test joke." in output.decode("utf-8")

Key Testing Benefits:

  • Unit tests require no mocks - domain is pure Python
  • Integration tests use real adapters - no complex stubbing
  • End-to-end tests verify real persistence across process boundaries
  • Adapters can be tested in isolation by implementing their port contract

Note on End-to-End Testing: While unit and integration tests focus on isolated components, end-to-end tests verify the complete application with all adapters working together as they would in production. These tests exercise the actual CLI commands, real file storage, and process boundaries to ensure the system works as a whole.

2. Type-Safe Development

  • Compile-time checking: basedpyright catches interface violations early
  • Better IDE support: Autocompletion works across layers
  • Refactoring safety: Type system prevents breaking changes

Example from ports_adapters.py:

# Type checker will catch if CommandlineJokesInteraction
# doesn't implement all methods from JokesInterationPort
jokes_interaction: JokesInterationPort = CommandlineJokesInteraction(joke_service)

2. Practical Flexibility

Swap implementations without changing domain:

# Development - use in-memory implementations
repository = InMemoryJokesRepository()
service = JokeService(repository)

# Production - switch to real database
repository = DatabaseJokesRepository()
service = JokeService(repository)  # Same interface, different implementation

3. Real-World Maintainability

  • Clear layer boundaries: Each file has a single responsibility
  • Easy testing: Mock ports for unit testing, test adapters separately
  • Gradual migration: Can introduce architecture incrementally

4. Technology Independence

  • Can migrate from one database to another
  • Can replace Flask with another HTTP framework
  • Can add new adapters (gRPC, GraphQL) without touching domain

5. Business Logic Focus

Developers can work on domain logic without worrying about:

  • Database details
  • HTTP concerns
  • External service protocols

Common Patterns

Pattern: Repository (Outbound Port)

Abstracts data persistence using ABC (as shown in ports_adapters.py):

from abc import ABC, abstractmethod

# Outbound Port (ABC)
class CredentialRepository(ABC):
    @abstractmethod
    async def save(self, credential: 'Credential') -> None: ...

    @abstractmethod
    async def find_by_id(self, id: str) -> 'Credential': ...

# Outbound Adapter
class DatabaseCredentialRepository(CredentialRepository):
    async def save(self, credential: 'Credential') -> None:
        # Save to database
        pass

    async def find_by_id(self, id: str) -> 'Credential':
        # Fetch from database
        pass

Key differences from ports_adapters.py example:

  • Uses async methods for real-world applications
  • Follows same ABC pattern as JokesRepositoryPort
  • basedpyright will enforce all abstract methods are implemented

Pattern: Use Case (Inbound Port)

Defines application operations using ABC (similar to ports_adapters.py):

from abc import ABC, abstractmethod

# Inbound Port (ABC)
class IssueCredentialUseCase(ABC):
    @abstractmethod
    async def execute(self, request: 'IssueCredentialRequest') -> 'IssueCredentialResponse': ...

# Implementation (Application Service)
class CredentialService(IssueCredentialUseCase):
    def __init__(self, repository: CredentialRepository):
        self.repository = repository

    async def execute(self, request: 'IssueCredentialRequest') -> 'IssueCredentialResponse':
        credential = create_credential(request)
        await self.repository.save(credential)
        return IssueCredentialResponse(credential)

# Inbound Adapter (Flask Handler)
async def issue_credential_handler(request: 'IssueCredentialRequest') -> 'IssueCredentialResponse':
    service = CredentialService(DatabaseCredentialRepository())
    return await service.execute(request)

Note: This follows the same pattern as JokeService and CommandlineJokesInteraction in ports_adapters.py

Pattern: Service Layer

Orchestrates use cases:

class CredentialService:
    def __init__(self, repository: CredentialRepository):
        self.repository = repository

    async def execute(self, request: 'IssueCredentialRequest') -> 'IssueCredentialResponse':
        # Use domain logic and infrastructure
        credential = create_credential(request)
        await self.repository.save(credential)
        return IssueCredentialResponse(credential)

Pattern: Domain Events

Decouple side effects from business logic:

# After credential issued
event_publisher.publish_credential_issued(credential)

# Adapter can implement this to:
# - Send email notification
# - Publish to message queue
# - Update search index
# - etc.

Anti-Patterns to Avoid

❌ Domain Depending on Infrastructure

# WRONG - domain importing database
from sqlalchemy import Session

class Credential:
    def save(self, session: Session):
        pass

❌ Business Logic in Adapters

# WRONG - validation logic in HTTP handler
def issue_credential(request):
    if request.subject_id == "":
        raise ValidationError()

❌ Circular Dependencies

# WRONG - ports depending on adapters
from adapters.repository import DatabaseRepository

Type Safety with basedpyright

The architecture leverages Python’s type system for compile-time safety:

# Run type checking
basedpyright src/

# Or for strict checking
basedpyright --strict src/

Benefits:

  • Catches missing interface implementations
  • Verifies dependency injection types
  • Ensures adapter compatibility with ports
  • Provides IDE autocompletion and refactoring support

Practical Implementation Guide

1. Start with Domain

# Define your core domain model first
class Credential:
    # Business logic and validation
    pass

2. Define Ports (Interfaces)

# Create ABC interfaces for what your domain needs
class CredentialRepository(ABC):
    @abstractmethod
    def save(self, credential: Credential) -> None: ...

3. Implement Adapters

# Create concrete implementations
class DatabaseCredentialRepository(CredentialRepository):
    def save(self, credential: Credential) -> None:
        # Actual database implementation
        pass

4. Create Application Service

# Coordinate domain logic using dependency injection
class CredentialService:
    def __init__(self, repository: CredentialRepository):
        self.repository = repository

5. Add Inbound Adapters

# Implement user-facing interfaces
class FlaskCredentialHandler:
    def __init__(self, service: CredentialService):
        self.service = service

Conclusion

Hexagonal architecture provides:

  • Clear separation of concerns
  • Type-safe development with basedpyright
  • Flexible infrastructure
  • Maintainable codebase
  • Technology independence

Getting Started: See ports_adapters.py for a complete, working example you can run and experiment with.

The initial setup cost is higher, but the long-term benefits are significant for complex domains and evolving requirements. Start small and grow the architecture as needed.

Ports Adapters example

"""
Jokes App using Hexagonal Architecture with Python ABC

This implementation demonstrates Hexagonal Architecture (Ports and Adapters pattern)
using Python's ABC module for interface enforcement and type safety.

Terminology:
- Inbound Ports: Interfaces for driving the application (user interaction)
- Outbound Ports: Interfaces for infrastructure needs (persistence, etc.)
- Inbound Adapters: Concrete implementations of inbound ports (CLI, web, etc.)
- Outbound Adapters: Concrete implementations of outbound ports (repositories, etc.)
"""

import json
import sys
import uuid
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TypedDict, override


def main() -> None:
    """Main entry point that parses command-line arguments and executes commands."""
    if len(sys.argv) < 2:
        print("Usage: python ports_adapters.py <command> [args...]")
        print("Commands:")
        print("  list          - List all jokes")
        print("  create <joke> - Create a new joke")
        print("  update <id> <joke> - Update a joke")
        return

    command = sys.argv[1]
    joke_repository = FileStorageRepository()
    joke_service = JokeService(joke_repository)
    jokes_interaction = CommandlineJokesInteraction(joke_service)

    if command == "list":
        jokes_interaction.list_jokes()
    elif command == "create" and len(sys.argv) > 2:
        joke_content = " ".join(sys.argv[2:])
        _ = jokes_interaction.create_joke(joke_content)
    elif command == "update" and len(sys.argv) > 3:
        joke_id = sys.argv[2]
        joke_content = " ".join(sys.argv[3:])
        _ = jokes_interaction.update_joke(joke_id, joke_content)
    else:
        print(f"Unknown command or missing arguments: {sys.argv[1:]}")
        print("Use 'python ports_adapters.py' for usage.")


class Joke:
    """
    Domain model with business logic for parsing joke content.

    Uses ABC-style interface enforcement through type hints and runtime validation.
    The from_content method enforces business rules about joke structure.
    """

    id: str
    title: str
    body: str

    @classmethod
    def from_content(cls, content: str) -> "Joke":
        """
        Business rule: Jokes must have title/body separated by punctuation.

        This enforces the domain constraint that jokes follow a specific format.
        The explicit type annotations enable static type checking of this contract.

        Format: "Title. Body content" or "Title? Body content"
        - Title: Text including the first punctuation (period or question mark)
        - Body: Text after the first punctuation
        """

        id = str(uuid.uuid4())

        # Find the first occurrence of either period or question mark
        period_pos = content.find(".")
        question_pos = content.find("?")

        # Determine which punctuation comes first
        if period_pos == -1 and question_pos == -1:
            raise ValueError("Invalid joke. Did you add a period or question mark?")

        # Use the first punctuation found
        if period_pos != -1 and (question_pos == -1 or period_pos < question_pos):
            split_pos = period_pos
            # Title includes punctuation, body is text after
            title = content[: split_pos + 1].strip()
            body = content[split_pos + 1 :].strip()
        else:
            split_pos = question_pos
            # Title includes punctuation, body is text after
            title = content[: split_pos + 1].strip()
            body = content[split_pos + 1 :].strip()

        # Handle empty body case
        if not body:
            raise ValueError("Invalid joke format: missing body after punctuation")

        return cls(id, title, body)

    def __init__(self, id: str, title: str, body: str) -> None:
        self.id = id
        self.title = title
        self.body = body

    @override
    def __repr__(self) -> str:
        return f"Joke(id={self.id}, title={self.title}, body={self.body})"

    @override
    def __str__(self) -> str:
        return f"{self.title}\n{self.body}"


class JokeService:
    """
    Application service that coordinates domain logic using dependency injection.

    Depends on JokesRepositoryPort (interface) not concrete implementations.
    This enables static type checking of dependencies while allowing runtime
    flexibility to swap implementations (e.g., InMemory vs Hardcoded repositories).
    """

    joke_repository: "JokesRepositoryPort"

    def __init__(self, joke_repository: "JokesRepositoryPort") -> None:
        self.joke_repository = joke_repository

    def run(self) -> None: ...

    def get_jokes(self) -> list[Joke]:
        return self.joke_repository.get_jokes()

    def create_joke(self, content: str) -> Joke:
        joke = Joke.from_content(content)
        return self.joke_repository.create_joke(joke)

    def update_joke(self, id: str, content: str) -> Joke:
        joke = Joke.from_content(content)
        joke.id = id
        return self.joke_repository.update_joke(joke)


class JokesInterationPort(ABC):
    """
    Inbound Port: Interface for driving the application via user interaction.

    This inbound port defines how external actors (users) interact with the application.
    ABC (Abstract Base Class) enforces that inbound adapters implement all methods.
    Type checkers catch missing implementations at development time.
    """

    @abstractmethod
    def run(self) -> None: ...

    @abstractmethod
    def get_jokes(self) -> list[Joke]: ...

    @abstractmethod
    def create_joke(self, content: str) -> Joke: ...

    @abstractmethod
    def update_joke(self, id: str, content: str) -> Joke: ...


class CommandlineJokesInteraction(JokesInterationPort):
    """
    Inbound Adapter: CLI implementation of the inbound port.

    This inbound adapter implements the JokesInterationPort contract using
    command-line arguments instead of interactive input. This makes it more
    suitable for testing and automation while still satisfying the ABC interface.
    """

    joke_service: JokeService

    def __init__(self, joke_service: JokeService):
        self.joke_service = joke_service

    @override
    def run(self) -> None:
        """Run the CLI application. Called by main() with parsed arguments."""
        # This method is called by main() after parsing arguments
        # The actual command execution is handled by individual methods
        pass

    def list_jokes(self) -> None:
        """List all jokes - implements 'list' command"""
        jokes = self.get_jokes()
        if not jokes:
            print("No jokes found.")
            return

        print(f"Found {len(jokes)} joke(s):")
        for i, joke in enumerate(jokes, 1):
            print(f"{i}. {joke}")

    @override
    def create_joke(self, content: str) -> Joke:
        """Create a new joke - implements 'create' command"""
        try:
            joke = self.joke_service.create_joke(content)
            print(f"Created joke: {joke}")
        except ValueError as e:
            print(f"Error creating joke: {e}")
            exit(1)

        return joke

    @override
    def update_joke(self, id: str, content: str) -> Joke:
        """Update a joke - implements 'update' command"""
        try:
            joke = self.joke_service.update_joke(id, content)
            print(f"Updated joke: {joke}")
        except Exception as e:
            print(f"Error updating joke: {e}")
            exit(1)

        return joke

    @override
    def get_jokes(self) -> list[Joke]:
        """Get all jokes from service layer"""
        return self.joke_service.get_jokes()


class JokesRepositoryPort(ABC):
    """
    Outbound Port: Interface for infrastructure persistence operations.

    Defines contract for persistence. ABC ensures adapters implement all methods.
    Enables type-safe DI in JokeService. Domain depends on interface only.
    """

    @abstractmethod
    def get_jokes(self) -> list[Joke]: ...

    @abstractmethod
    def create_joke(self, joke: Joke) -> Joke: ...

    @abstractmethod
    def update_joke(self, joke: Joke) -> Joke: ...


class InMemoryJokesRepository(JokesRepositoryPort):
    """
    Outbound Adapter: In-memory implementation of the outbound port.

    Satisfies JokesRepositoryPort contract using in-memory storage. Can be swapped with
    other implementations without changing JokeService or domain logic.
    """

    jokes: list[Joke]

    def __init__(self) -> None:
        self.jokes = []

    @override
    def get_jokes(self) -> list[Joke]:
        return self.jokes

    @override
    def create_joke(self, joke: Joke) -> Joke:
        self.jokes.append(joke)
        return joke

    @override
    def update_joke(self, joke: Joke) -> Joke:
        for i, j in enumerate(self.jokes):
            if j.id == joke.id:
                self.jokes[i] = joke
                return joke
        raise StorageError("Joke not found")


class JokeData(TypedDict):
    id: str
    title: str
    body: str


class FileStorageRepository(JokesRepositoryPort):
    """
    Outbound Adapter: File-based implementation of the outbound port.

    Persists jokes as JSON files in a temporary directory. This provides
    real persistence across command executions, enabling proper end-to-end testing.
    """

    def __init__(self, storage_dir: str = "/tmp/jokes_db") -> None:
        self.storage_dir: Path = Path(storage_dir)

        # Create storage directory if it doesn't exist
        self.storage_dir.mkdir(exist_ok=True)

    def _get_joke_path(self, joke_id: str):
        """Get the file path for a joke"""
        return self.storage_dir / f"{joke_id}.json"

    def _read_joke(self, joke_id: str) -> Joke:
        """Read a joke from file"""
        joke_path = self._get_joke_path(joke_id)
        if not joke_path.exists():
            raise StorageError(f"Joke {joke_id} not found")

        with open(joke_path, "r") as f:
            data: JokeData = json.load(f)  # pyright: ignore[reportAny] We don't want to override json.load's type

        return Joke(id=data["id"], title=data["title"], body=data["body"])

    def _write_joke(self, joke: Joke) -> None:
        """Write a joke to file"""
        joke_path = self._get_joke_path(joke.id)
        data = {"id": joke.id, "title": joke.title, "body": joke.body}

        with open(joke_path, "w") as f:
            json.dump(data, f, indent=2)

    def _delete_joke(self, joke_id: str) -> None:
        """Delete a joke file"""
        joke_path = self._get_joke_path(joke_id)
        if joke_path.exists():
            joke_path.unlink()

    @override
    def get_jokes(self) -> list[Joke]:
        """Get all jokes from file storage"""
        if not self.storage_dir.exists():
            return []

        jokes: list[Joke] = []
        for json_file in self.storage_dir.glob("*.json"):
            try:
                with open(json_file, "r") as f:
                    data: JokeData = json.load(f)  # pyright: ignore[reportAny] We don't want to override json
                joke = Joke(id=data["id"], title=data["title"], body=data["body"])
                jokes.append(joke)
            except (json.JSONDecodeError, KeyError, IOError):
                # Skip corrupted files
                continue

        return jokes

    @override
    def create_joke(self, joke: Joke) -> Joke:
        """Create a new joke in file storage"""
        self._write_joke(joke)
        return joke

    @override
    def update_joke(self, joke: Joke) -> Joke:
        """Update a joke in file storage"""
        if not self._get_joke_path(joke.id).exists():
            raise StorageError(f"Joke {joke.id} not found")

        self._write_joke(joke)
        return joke


class StorageError(Exception):
    pass


if __name__ == "__main__":
    main()

Ports Adapters example test setup

"""
Test suite for ports_adapters.py demonstrating hexagonal architecture testing approach.

This module shows three levels of testing for hexagonal architecture:
1. End-to-end tests: Test complete user journeys through the CLI
2. Integration tests: Test (parts of) the domain in isolation, replacing
   external dependencies with other adapters
3. Unit tests: Test domain logic in isolation
"""

import subprocess

import pytest

from .ports_adapters import (
    InMemoryJokesRepository,
    Joke,
    JokeService,
)

# INTEGRATION TESTS
# Test (parts of) the domain in isolation, replacing external dependencies with
# other adapters
# END-TO-END TESTS
# Test complete user journeys through the entire application


class TestEndToEnd:
    """
    End-to-end tests for the joke application.

    These tests verify complete user journeys through the application,
    testing the integration of all components.
    """

    def test_list_jokes(self):
        """
        End-to-end test: Add and list jokes.

        Tests the complete flow of listing jokes, including repository
        interaction and service layer processing.
        """
        import os
        import shutil

        # Clean up any existing storage files to start with clean state
        storage_dir = "/tmp/jokes_db"
        if os.path.exists(storage_dir):
            shutil.rmtree(storage_dir)

        # Run the app by executing ports_adapters.py on the commandline
        output = subprocess.check_output(
            [
                "python",
                "docs/ports_adapters.py",
                "create",
                "Why did the chicken cross the road? To get to the other side.",
            ]
        )
        assert "Why did the chicken cross the road?" in output.decode("utf-8")

        output = subprocess.check_output(["python", "docs/ports_adapters.py", "list"])
        assert "Why did the chicken cross the road?" in output.decode("utf-8")

        # Clean up after test
        if os.path.exists(storage_dir):
            shutil.rmtree(storage_dir)


# ============================================================================
# INTEGRATION TESTS
# Test interaction between components
# ============================================================================


class TestIntegration:
    """
    Integration tests for the joke application.

    These tests verify the interaction between components, ensuring
    that the service layer works correctly with different repository
    implementations.
    """

    def test_list_jokes(self):
        """
        Integration test: Service with InMemoryJokesRepository.

        Tests that the service layer works with the in-memory adapter
        and returns the expected predefined jokes.
        """
        repository = InMemoryJokesRepository()
        repository.jokes = [
            Joke(
                "1", "Why did the chicken cross the road?", "To get to the other side."
            ),
            Joke("2", "What do you call a fake noodle?", "An impasta."),
        ]
        service = JokeService(repository)

        jokes = service.get_jokes()

        # Verify we get the predefined jokes
        assert len(jokes) == 2
        assert jokes[0].title == "Why did the chicken cross the road?"
        assert jokes[1].title == "What do you call a fake noodle?"

    def test_insert_jokes(self):
        """
        Integration test: Service with InMemoryJokesRepository.

        Tests creating jokes through the service layer using the
        in-memory adapter implementation.
        """
        repository = InMemoryJokesRepository()
        service = JokeService(repository)

        # Create multiple jokes
        joke1 = service.create_joke("First joke. This is the body.")
        joke2 = service.create_joke("Second joke? This is also the body.")

        # Verify jokes were created and stored
        jokes = service.get_jokes()
        assert len(jokes) == 2
        assert jokes[0].id == joke1.id
        assert jokes[1].id == joke2.id

    def test_update_joke(self):
        """
        Integration test: Updating jokes through service layer.

        Tests the complete update flow: create → update → verify.
        """
        repository = InMemoryJokesRepository()
        service = JokeService(repository)

        # Create initial joke
        created_joke = service.create_joke("Original joke. Original body.")
        original_id = created_joke.id

        # Update the joke
        updated_joke = service.update_joke(original_id, "Updated joke. Updated body.")

        # Verify update worked
        assert updated_joke.id == original_id  # ID should remain the same
        assert updated_joke.title == "Updated joke."
        assert updated_joke.body == "Updated body."

        # Verify only one joke exists (replaced, not added)
        jokes = service.get_jokes()
        assert len(jokes) == 1
        assert jokes[0].id == original_id


# ============================================================================
# UNIT TESTS
# Test domain logic in isolation
# ============================================================================


class TestJokeDomain:
    """
    Unit tests for the Joke domain model.

    These tests focus on domain logic in complete isolation, testing
    business rules without any infrastructure dependencies.
    """

    def test_joke_parsing_happy_path_with_period(self):
        """
        Unit test: Happy path parsing with period separator.

        Tests the core business rule that jokes must have title/body
        separated by punctuation.
        """
        content = "Why did the chicken cross the road. To get to the other side."
        joke = Joke.from_content(content)

        assert joke.title == "Why did the chicken cross the road."
        assert joke.body == "To get to the other side."
        assert len(joke.id) > 0

    def test_joke_parsing_happy_path_with_question_mark(self):
        """
        Unit test: Happy path parsing with question mark separator.
        """
        content = "What do you call a fake noodle? An impasta."
        joke = Joke.from_content(content)

        assert joke.title == "What do you call a fake noodle?"
        assert joke.body == "An impasta."

    def test_joke_parsing_edge_case_no_punctuation(self):
        """
        Unit test: Edge case with no valid punctuation.

        Tests that the domain enforces its constraints by raising
        appropriate errors for invalid input.
        """
        content = "No punctuation here"

        with pytest.raises(ValueError) as exc_info:
            _ = Joke.from_content(content)

        assert "period or question mark" in str(exc_info.value)

    def test_joke_parsing_edge_case_multiple_punctuation(self):
        """
        Unit test: Edge case with multiple punctuation marks.

        Tests that the parser handles the first valid punctuation
        mark correctly.
        """
        content = "Complex joke. With multiple. Periods? And questions?"
        joke = Joke.from_content(content)

        # Should split on first punctuation (period)
        assert joke.title == "Complex joke."
        assert joke.body == "With multiple. Periods? And questions?"

    def test_joke_parsing_preserves_punctuation_in_title(self):
        """
        Unit test: Punctuation preservation in titles.

        Verifies that punctuation in titles is preserved correctly.
        """
        content = "Why? Because. That's why."
        joke = Joke.from_content(content)

        # Should split on first punctuation (question mark)
        assert joke.title == "Why?"
        assert joke.body == "Because. That's why."


if __name__ == "__main__":
    # Run tests
    _ = pytest.main([__file__, "-v"])

Test Doubles

Test doubles are objects that stand in for real dependencies in unit tests. All shared test doubles live in tests/unit/test_doubles.py.

Naming Conventions

Stub

Returns a hardcoded, preprogrammed response. Does not record how it was called.

Use when a dependency must satisfy a type contract but its exact behaviour is irrelevant to the test.

Examples: AccessControlStub, ConfigRepoStub, MetadataServiceStub

Spy

Records how it was called and returns a preprogrammed response (or delegates to a real implementation). Lets the test assert on interactions.

Use when the test needs to verify that a collaborator was called with specific arguments.

Examples: IssuerAgentSpy, OfferServiceSpy

Dummy

Satisfies a type constraint but is never actually invoked. Carries no behaviour.

Use when a dependency is required by a constructor but irrelevant to the behaviour under test.

Mock

Returns a response based on logic — essentially a lightweight rule engine. Rarely needed; prefer a Spy or Stub instead.

Usage

Say we have a JokeService that relies on JokeRepository and FunnyScoreService. When we test JokeService in isolation (unit tests), we only want to test JokeService itself, and how it interacts with this JokeRepository and FunnyScoreService. But we do not want to test the (inner) working of this JokeRepository or the FunnyScoreService, that’s the responsibility of their respective unit tests.

When testing inner working: Pass in a JokeRepositoryStub and FunnyScoreServiceStub. When testing the interaction between JokeService and e.g. FunnyScoreService - e.g. that it calls it with the right arguments, use FunnyScoreServiceSpy. When the inner working or interaction depends on more complex interaction between its dependencies, for example “when funny score service returns 5 or higher, also check hilariousness at the funny score service”. For such interactions, we still do not use the actual FunnyScoreService but a FunnyScoreServiceMock.

Rules

  • Doubles live in tests/unit/test_doubles.py, not scattered across individual test files.
  • Test-specific inline doubles (e.g. a SpyAccessControl inside a single test) are acceptable when they are too narrow to share.
  • Never test the doubles themselves, only test the subject under test.
  • If a double is getting complex, it is a sign the collaborator’s API should be simplified instead.

End-to-End Testing

End-to-end tests verify complete, user-visible journeys through the running system. They make real HTTP requests against all services and assert on observable outcomes — what a user or API consumer would see.

When to write e2e tests

Write an e2e test for new features and for user-facing bugs that are common or visible enough to affect users in production. See the TDD skill for the full decision guide on when to use e2e vs integration vs unit tests.

Running Tests

just test-e2e

This starts all required services via Podman Compose and waits for them to be healthy before running the test suite. Test fixtures never start or stop services themselves — if services are not running the tests fail with a clear connection error.

Organisation

One test file per business feature in tests/e2e/, no subdirectories. Support files (JSON schemas, key material, shared fixtures) live alongside the test files in the same directory.

Architecture Decision Records ADR.

All important technical decisions are recorded in an ADR. The ADR github organisation has introductory and other resources.

Higher level ADRs, like business- or domain logic, requirements and other team-wide decisions are kept in EduCredentials ADRs on Confluence.

Developers: Template

When adding an ADR here, use the following template, but keep it simple: remove anything but the bare necessary parts.

---
# These are optional elements. Feel free to remove any of them.
status: {proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)}
date: {YYYY-MM-DD when the decision was last updated}
deciders: {list everyone involved in the decision}
consulted: {list everyone whose opinions are sought (typically subject-matter experts); and with whom there is a two-way communication}
informed: {list everyone who is kept up-to-date on progress; and with whom there is a one-way communication}
---
# {short title of solved problem and solution}

## Context and Problem Statement

{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story.
 You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.}

<!-- This is an optional element. Feel free to remove. -->
## Decision Drivers

* {decision driver 1, e.g., a force, facing concern, …}
* {decision driver 2, e.g., a force, facing concern, …}
* … <!-- numbers of drivers can vary -->

## Considered Options

* {title of option 1}
* {title of option 2}
* {title of option 3}
* … <!-- numbers of options can vary -->

## Decision Outcome

Chosen option: "{title of option 1}", because
{justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | … | comes out best (see below)}.

<!-- This is an optional element. Feel free to remove. -->
### Consequences

* Good, because {positive consequence, e.g., improvement of one or more desired qualities, …}
* Bad, because {negative consequence, e.g., compromising one or more desired qualities, …}
* … <!-- numbers of consequences can vary -->

<!-- This is an optional element. Feel free to remove. -->
## Validation

{describe how the implementation of/compliance with the ADR is validated. E.g., by a review or an ArchUnit test}

<!-- This is an optional element. Feel free to remove. -->
## Pros and Cons of the Options

### {title of option 1}

<!-- This is an optional element. Feel free to remove. -->
{example | description | pointer to more information | …}

* Good, because {argument a}
* Good, because {argument b}
<!-- use "neutral" if the given argument weights neither for good nor bad -->
* Neutral, because {argument c}
* Bad, because {argument d}
* … <!-- numbers of pros and cons can vary -->

### {title of other option}

{example | description | pointer to more information | …}

* Good, because {argument a}
* Good, because {argument b}
* Neutral, because {argument c}
* Bad, because {argument d}
* …

<!-- This is an optional element. Feel free to remove. -->
## More Information

{You might want to provide additional evidence/confidence for the decision outcome here and/or
 document the team agreement on the decision and/or
 define when this decision when and how the decision should be realized and if/when it should be re-visited and/or
 how the decision is validated.
 Links to other decisions and resources might here appear as well.}

ADR001: Python and Flask for service

Statusaccepted
Date2025-04-30
DecidersDaniel Ostkamp, Thomas Kalverda, Bèr Kessels
ConsultedSee deciders
InformedSee deciders

After an initial PoC with Rust, we decided to build the issuer service in Python, without any framework.

Context and Problem Statement

ADR-021 Issuer service

Wrap SSI-Agent and/or edci-issuer in own ec-issuer. Starting with SSI-Agent.

ADR-009 Rust for Cryptographic Operations and ADR-005 Polyglot Microservices Strategy

Core services: Python/Django (Credential, Organization, Identity) Signing service: Rust (Impierce-based) - Future services: Language appropriate to problem domain

ADR-004 requires us to implement ports and adapters. The storage (database) and HTTP (the API) are such adapters and must adhere to this pattern.

Decision Drivers

  • Future maintenance
  • Available team and -knowledge

Considered Options

  • Rust
  • Python

Decision Outcome

Chosen option: “Python”

Since the signing service is in rust, and the issuer-service wraps this to add our business-logic, API and other integrations, the issuer-service falls into the “Core services” category.

We use the latest stable Python version at time of writing: 3.14.x.

Package-management, version management, linting, formatting, typechecking all go through the astral tools (uv, ruff, ty etc)

Consequences

  • Good: Python is well-known and has many developers available to work on.
  • Good: Boilerplate is minimal since Python is a high-order language, leaving e.g. memory management to the runtime.
  • Neutral: Python performs poorly compared to rust, but since this service is not performance-critical, the extra cost is minimal.
  • Bad: Runtime management-, building and setup requires additional, complex tooling like Containers to maintain parity and allow easy on-boarding.
  • Bad: We must rely on external type-checking and linting to avoid many runtime bugs and errors that other languages avoid in compile-time.
  • Bad: We must use a type-checker and implement interface contracts using abc.ABC to adhere to the “Interfaces” part of the Ports and Adapters Architecture. Python does not have native interface support.
  • Bad: We must rely on external linting and type checking since Python does not have native tooling for this.

ADR002: Use ABC instead of Protocol for Interface Definition

Statusaccepted
Date2024-07-15
DecidersBèr Kessels, Daniel Ostkamp, Thomas Kalverda
ConsultedSee deciders
InformedSee deciders

Context and Problem Statement

We need to enforce interface contracts in our hexagonal architecture implementation. Python offers two main approaches:

  • typing.Protocol (structural typing, duck typing with type hints)
  • abc.ABC with @abstractmethod (nominal typing, abstract base classes)

Both approaches work with type checkers like basedpyright, but they have different characteristics.

Decision Drivers

  • Development-time feedback and IDE support
  • Runtime enforcement capabilities
  • Team familiarity and learning curve
  • Consistency with existing codebase patterns
  • Type checker compatibility and strictness

Considered Options

  • Option 1: Use typing.Protocol for all interfaces
  • Option 2: Use abc.ABC with @abstractmethod for all interfaces
  • Option 3: Mix both approaches based on context

Decision Outcome

Chosen option: “Use abc.ABC with @abstractmethod for all interfaces”, as demonstrated in ports_adapters.py.

Positive Consequences

  • Better development-time checks: ABC provides clearer error messages in IDEs and type checkers
  • Runtime enforcement: Abstract methods cannot be instantiated, catching errors earlier
  • Explicit contracts: @abstractmethod makes interface requirements visibly explicit
  • Consistency: Single approach throughout codebase reduces cognitive load
  • basedpyright compatibility: Works well with our chosen type checker
  • Familiar pattern: More Python developers are familiar with ABC than Protocol

Negative Consequences

  • Nominal typing: Less flexible than structural typing (classes must explicitly inherit)
  • Slightly more boilerplate: Requires explicit inheritance and decorators
  • Runtime overhead: Minimal but present abstract method checking

Rationale

While Protocol offers more flexibility through structural typing, ABC provides better development-time feedback which aligns with our goal of catching errors early. The explicit nature of ABC interfaces makes the code more self-documenting and easier to understand for team members.

The ports_adapters.py example demonstrates this approach effectively:

from abc import ABC, abstractmethod

class JokesRepositoryPort(ABC):
    """Outbound Port interface using ABC"""
    @abstractmethod
    def get_jokes(self) -> list[Joke]: ...

    @abstractmethod
    def create_joke(self, joke: Joke) -> Joke: ...

# Type checker and runtime both enforce implementation
class InMemoryJokesRepository(JokesRepositoryPort):
    def get_jokes(self) -> list[Joke]:
        return self.jokes
    
    def create_joke(self, joke: Joke) -> Joke:
        self.jokes.append(joke)
        return joke

This approach gives us both compile-time type checking with basedpyright and runtime safety through Python’s ABC mechanism.

Alternatives Considered

Protocol Approach

from typing import Protocol

class JokesRepositoryPort(Protocol):
    def get_jokes(self) -> list[Joke]: ...
    def create_joke(self, joke: Joke) -> Joke: ...

# Structural typing - no inheritance required
class InMemoryJokesRepository:
    def get_jokes(self) -> list[Joke]:
        return self.jokes
    
    def create_joke(self, joke: Joke) -> Joke:
        self.jokes.append(joke)
        return joke

Rejected because: Less explicit contracts, potentially confusing for team members, and weaker IDE support.

Mixed Approach

Use Protocol for some interfaces and ABC for others based on context.

Rejected because: Increases cognitive load, creates inconsistency, and provides minimal benefit over single approach.

ADR003: Expose ssi-agent as peer service alongside ec-issuer

Statusaccepted
Date2025-05-08
DecidersDaniel Ostkamp, Bèr Kessels
ConsultedSee deciders
InformedSee deciders

Context and Problem Statement

The ec-issuer service was designed to wrap Unime’s ssi-agent completely, hiding it behind a proxy layer. This approach has proven difficult in practice due to how the ssi-agent operates, particularly with the OID4VCI (OpenID for Verifiable Credentials Issuance) flow.

Attempting to proxy all ssi-agent functionality through ec-issuer requires significant workarounds in the proxy layer to make ec-issuer appear as the publisher. This means ec-issuer ends up re-implementing functionality that the ssi-agent already provides, creating unnecessary duplication and complexity.

The OID4VCI flow, in particular, is designed to work directly with the credential issuer’s endpoints. By placing ssi-agent behind ec-issuer, we fight against this design rather than leveraging it.

Decision Drivers

  • Reduce complexity and maintenance burden
  • Leverage ssi-agent’s built-in OID4VCI functionality
  • Follow Unime’s integration guidelines
  • Minimize duplicate functionality between services
  • Maintain clear separation of concerns

Considered Options

  • Option 1: Continue wrapping ssi-agent behind ec-issuer - Maintain the current architecture where all ssi-agent endpoints are proxied through ec-issuer
  • Option 2: Expose ssi-agent as peer service next to ec-issuer - Allow direct public access to specific ssi-agent paths, with ec-issuer handling only its own responsibilities

Decision Outcome

Chosen option: “Expose ssi-agent as peer service next to ec-issuer”, because this approach aligns with the ssi-agent’s design and Unime’s integration guidelines, eliminates the need for proxy workarounds, and reduces duplicate functionality.

Following the Unime ssi-agent integration guidelines, ec-issuer will no longer handle the OID4VCI flow directly. Instead, it will manage the credential issuance lifecycle at a higher level.

Consequences

  • Good, because ec-issuer no longer needs complex proxy logic to hide ssi-agent
  • Good, because we leverage ssi-agent’s native OID4VCI support
  • Good, because reduced code complexity and maintenance burden
  • Good, because clearer separation of concerns between services
  • Neutral, because ec-issuer’s role shifts from direct flow handler to orchestration service
  • Bad, because ssi-agent endpoints are directly exposed, requiring proper security configuration
  • Bad, because API gateway configuration becomes slightly more complex with two services to manage

Pros and Cons of the Options

Option 1: Continue wrapping ssi-agent behind ec-issuer

  • Bad, because requires complex proxy workarounds for OID4VCI flow
  • Bad, because duplicates functionality already provided by ssi-agent
  • Bad, because fights against ssi-agent’s designed integration patterns
  • Bad, because increases maintenance burden and complexity
  • Good, because single entry point for all credential-related operations

Option 2: Expose ssi-agent as peer service next to ec-issuer

  • Good, because aligns with ssi-agent’s architecture and Unime’s guidelines
  • Good, because eliminates proxy workarounds and hacking
  • Good, because reduces duplicate code and maintenance
  • Good, because clearer separation: ssi-agent handles OID4VCI, ec-issuer handles business logic
  • Neutral, because requires public exposure of .well-known/ and openid4vci/ paths
  • Bad, because two services instead of one for clients to interact with

Implementation Details

Under this architecture:

  • Public access is allowed to all .well-known/ and openid4vci/ paths of the ssi-agent
  • All other ssi-agent paths remain internal or are disabled in the API gateway
  • ec-issuer’s responsibilities are:
    • Manage templates on the ssi-agent
    • Manage credential configurations on the ssi-agent
    • Create credentials by providing user-data and template-id to the ssi-agent
    • Create an offer on the ssi-agent
    • Send and/or show this offer to a user
    • Query the status of the offer
    • Provide an event endpoint where ssi-agent can deliver its events for future integration with our event-bus and audit-trail

More Information

Agent Context

This includes the AGENTS.md and agent skills.

See agentsmd.io for information on AGENTS.md. See agentskills.io for information on agent skills.

Educredential Issuer

Credential service to issue Open Badges 3.0 and European Learner Model (ELM) credentials.

Development Environment

  • Python, using uv, ruff and basedpyright
  • Justfile and just for common tasks

Just

run common commands with just. See just --list for a list of available commands. a few important ones:

  • develop # Run the application in development mode. Command blocks, so use backgrounding & if needed.
  • dependencies # (re)start dependency services
  • docs # Run mdbook to preview the docs
  • lint # Run all quality checks
  • test # Run all tests

Project Structure

  • src/ application code. The main entry point is src/main.py. See below Running the app for details on how to run.
  • tests/ test code. See below Testing Instructions for details.
  • docs/ documentation, glossary, overview and backgrounds, uses mdbook structure. Contains symlinks to files elswhere in this project.
  • docs/src/adr Architecture Decision Records, ADRs.

Running the app

podman compose runs the services that this application depends on, and can run the service itself as well.

  • just dependencies to (re)start all services except the ec-issuer.
  • just develop to start the ec-issuer in development mode.

Important basic rules you may NEVER violate

  • Use just to run tasks.
  • Use uv only for dependency management. Never use pip or poetry.
  • Never run python directly. When just or uv don’t suffice, reconsider what you are solving. When stuck ask user for guidence.
  • Use podman compose and podman for lower level container management and inspection.
  • Never make bash scripts to run code.
  • Only commit code that has no linting and typing errors. Never commit without checking just lint one last time.
  • Only commit code that passes all tests. Never commit without running just test one last time and seeing all tests pass.
  • If you get stuck, don’t violate these rules, instead ask for guidance.

If you find yourself running python, custom bash scripts, python scripts, pip, pyenv or poetry you are vioalting the project rules and probably will severely break the project.

Code Style Guidelines

  • ruff defaults. Check with just lint. Format with uv run ruff format
  • basedpyright defaults. Check with just lint. Some general guidelines to adhere to these basedpyright defaults:
    • never use Any
    • never use Unknown or partially Unknown
  • Structured in modules, following the Screaming Architecture pattern.
  • Follows Hexagonal Architecture with Ports and Adapters.
  • Do not add comments to code that explain what the code does. Only add comments why we do it this way, if that is unclear, unexpected or uncommon.
  • Make the code self-documenting by using good naming and structure.

Lower level code guidelines

  • response.json() from request lib is Any or Partially unknown. Avoid this. Use msgspec and Dataclasses instead
  • use ABC for adapters. Python lacks interfaces, ABC is how we mimic interfaces.
  • use _UnderScore, _UNDERSCORE, _under_score() etc to mark internals in modules as private. Python lacks visibility scoping, we mimic that with the underscore convention. Ruff and BasedPyright use that convention.

Project Context

  • Wrapper around third party issuance service that holds our business logic, hides third party details and provides a clean, pragmatic API.
  • Integrates our services with this third party issuance service

Testing Instructions

  • Ensure the dependecies are running. See above, Running the app.
  • Ensure the ec-issuer service is running. See above, Running the app.
  • Run tests with just test. just test unit, just test ./tests/unit/some_specific_test.py etc to run specific directories or files.
  • Do not run tests in isolation. Run them all.
  • Do not run tests with python or uv commands. Only through just.
  • Use the /python-tdd skill

Linting and Typing Instructions

  • Run linter with just lint.
  • All errors and all warning must be fixed.
  • Never add pyright ignore comments. When you cannot solve a typing error, ask the human for instructions instead of adding ignore comments.

Ask for permission or guidance

  • For any architectural change, stop and ask the human. Provide alternatives and describe their pros and cons.
  • Exceptions to the guidelines and pitfalls are not allowed without explicit permission from the human. Stop and ask and give context on why we need an exception.

Common Pitfalls to Avoid

  • DON’T: Create new files unless necessary
  • DON’T: Use print, logging to stdout for debugging. Prefer tests
  • DON’T: Ignore linting and type checking errors or warnings
  • DON’T: Revert or overwrite existing changes in files without explicit instructions. Other agents or a human, may be working on something as well.
  • DO: Add type hints to all code
  • DO: Add pydocstring to all functions and classes
  • DO: Check existing components before creating new ones
  • DO: Remove existing pyright-ignore comments and replace then with proper type hints, typing or other solutions
  • DO: Follow established patterns in the codebase
  • DO: Keep functions small and focused

Skill adr SKILL.md


name: ADR editor description: Use this skill to read, write and update Architecture Decision Records, ADRs Keywords: ADR, documentation

Reading ADRs

ADRs are located in the ADR directory. ADR diretory can be found in the AGENT.md of the project. Other common locations to look, are ./docs/adrs?, ./docs/adr, docs/src/adrs, etc.

ADRs have the number and title in their filename. The numbers follow order. ADRs may be superceded, in which case the ADR is no longer valid and replaced by another one. The one replacing it should be linked from the now deprecated ADR, but will also be linked to, from the ADR that supercedes the now-deprecated one.

Writing ADRs

Process

  1. Gather requirements

    • ask user about:
    • What exact decision are we recording?
    • What is the problem we must solve?
    • Why must we decide on this?
    • Any reference materials to include?
    • gather alternatives:
    • What other options are there?
    • Is one solution obviously superior or must we research them further
    • Give a summarized list of pros and cons for each alternative
  2. Draft the ADR

  • Look up current highest ADR number
  • Decide on a name
  • Create a new file in the ADR directory
  • new ADR with proper metadata, like current name, status, author, etc.
  • add summary, problem statement and possible solutions
  • add alternatives considered with their pros and cons
  1. Review with user - present draft and ask:
    • Does this cover your decision?
    • Did we cover all alternatives and options?
    • Anything missing or unclear?
    • Should any section be more/less detailed?
    • Must we further drill down on the alternatives?

Integrate

Add the new ADR to the docs, by linkin it in ./docs/src/SUMMARY.md

Guidelines

DO:

  • Write concise and clear
  • Give concrete examples
  • Always check URLS you add, check if they are valid and point to the expected resource.
  • Write it as draft.

DON’t:

  • Put a status to accepted unless explicitly instructed by the user
  • Introduce new terminology that does not already appear in other ADRs or the project wide GLOSSARY. Check AGENT.md for the location of the Glossary

Updating ADRs

ADRs can be updated untill they are “final”.

Updating “follows” exact the rules and context form Writing.

In addition, when updating, check the Template for additional fields or chapters that may be added.

A “final” ADR can only be changed if it is superceded by another ADR, in which case the only changes are the change of the status to “superceded” and a link to the new ADR.

Template

# {short title of solved problem and solution}

| | |
|---|---|
| Status | {proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)} |
| Date | {YYYY-MM-DD when the decision was last updated} |
| Deciders | {list everyone involved in the decision} |
| Consulted | {list everyone whose opinions are sought (typically subject-matter experts); and with whom there is a two-way communication} |
| Informed | {list everyone who is kept up-to-date on progress; and with whom there is a one-way communication} |

## Context and Problem Statement

{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story.
 You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.}

<!-- This is an optional element. Feel free to remove. -->
## Decision Drivers

* {decision driver 1, e.g., a force, facing concern, …}
* {decision driver 2, e.g., a force, facing concern, …}
* … <!-- numbers of drivers can vary -->

## Considered Options

* {title of option 1}
* {title of option 2}
* {title of option 3}
* … <!-- numbers of options can vary -->

## Decision Outcome

Chosen option: "{title of option 1}", because
{justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | … | comes out best (see below)}.

<!-- This is an optional element. Feel free to remove. -->
### Consequences

* Good, because {positive consequence, e.g., improvement of one or more desired qualities, …}
* Bad, because {negative consequence, e.g., compromising one or more desired qualities, …}
* … <!-- numbers of consequences can vary -->

<!-- This is an optional element. Feel free to remove. -->
## Validation

{describe how the implementation of/compliance with the ADR is validated. E.g., by a review or an ArchUnit test}

<!-- This is an optional element. Feel free to remove. -->
## Pros and Cons of the Options

### {title of option 1}

<!-- This is an optional element. Feel free to remove. -->
{example | description | pointer to more information | …}

* Good, because {argument a}
* Good, because {argument b}
<!-- use "neutral" if the given argument weights neither for good nor bad -->
* Neutral, because {argument c}
* Bad, because {argument d}
* … <!-- numbers of pros and cons can vary -->

### {title of other option}

{example | description | pointer to more information | …}

* Good, because {argument a}
* Good, because {argument b}
* Neutral, because {argument c}
* Bad, because {argument d}
* …

<!-- This is an optional element. Feel free to remove. -->
## More Information

{You might want to provide additional evidence/confidence for the decision outcome here and/or
 document the team agreement on the decision and/or
 define when this decision when and how the decision should be realized and if/when it should be re-visited and/or
 how the decision is validated.
 Links to other decisions and resources might here appear as well.}

Deep Modules

From “A Philosophy of Software Design”:

Deep module = small interface + lots of implementation

┌─────────────────────┐
│   Small Interface   │  ← Few methods, simple params
├─────────────────────┤
│                     │
│                     │
│  Deep Implementation│  ← Complex logic hidden
│                     │
│                     │
└─────────────────────┘

Shallow module = large interface + little implementation (avoid)

┌─────────────────────────────────┐
│       Large Interface           │  ← Many methods, complex params
├─────────────────────────────────┤
│  Thin Implementation            │  ← Just passes through
└─────────────────────────────────┘

When designing interfaces, ask:

  • Can I reduce the number of methods?
  • Can I simplify the parameters?
  • Can I hide more complexity inside?

Python: Interfaces and Privacy

Python has no interface keyword and no access modifiers. We use two conventions to enforce deep module design:

ABC as interface. Define the public surface of a module as an ABC with @abstractmethod. Callers depend on the ABC, not the concrete class. This is how Ports are defined.

from abc import ABC, abstractmethod

class JokesRepositoryPort(ABC):
    """Public interface: two methods, simple params."""

    @abstractmethod
    def get_jokes(self) -> list[Joke]: ...

    @abstractmethod
    def create_joke(self, joke: Joke) -> Joke: ...

_underscore as private. Everything that is not part of the public interface gets a _ prefix. Ruff and basedpyright treat _ names as internal and warn on external access. Keep the complex logic in _ helpers; keep the public surface small.

class FileStorageRepository(JokesRepositoryPort):
    """Deep implementation: rich logic hidden behind _ helpers."""

    def get_jokes(self) -> list[Joke]:
        return [self._read_joke(p) for p in self._storage_dir.glob("*.json")]

    def create_joke(self, joke: Joke) -> Joke:
        self._write_joke(joke)
        return joke

    def _read_joke(self, path: Path) -> Joke:
        ...  # file I/O, parsing, error handling

    def _write_joke(self, joke: Joke) -> None:
        ...  # serialisation, atomic write

The public interface is two methods. All the complexity is hidden. Tests only call get_jokes and create_joke.

Module Structure

In this project, a module is a directory with an __init__.py. Modules are one level deep — no modules inside modules.

src/
  issuer_agent/       ← module
    __init__.py
    ssi_agent_adapter.py
  metadata/           ← module
    __init__.py
    metadata_repository.py
    postgresql_adapter.py

Each module exposes its public API through __init__.py. Internal files within a module are implementation details — they may use _ prefixed names freely. Do not create sub-modules (a directory inside a module directory).

Interface Design for Testability

Good interfaces make testing natural:

  1. Accept dependencies, don’t create them

    Depend on a Port (ABC), not a concrete adapter. Inject at construction time.

    # Testable: dependency is injected, can be swapped in tests
    class OrderService:
        def __init__(self, payment_gateway: PaymentGatewayPort) -> None:
            self._payment_gateway = payment_gateway
    
    # Hard to test: creates its own dependency internally
    class OrderService:
        def __init__(self) -> None:
            self._payment_gateway = StripeGateway(settings.STRIPE_KEY)
    
  2. Return results, don’t produce side effects

    # Testable: assert on the return value
    def calculate_discount(cart: Cart) -> Discount:
        ...
    
    # Hard to test: must inspect state after the call
    def apply_discount(cart: Cart) -> None:
        cart.total -= _compute_discount(cart)
    
  3. Small surface area

    • Fewer methods = fewer tests needed
    • Fewer params = simpler test setup
    • Hide complexity behind _ prefixed helpers (see deep-modules.md)

When to Mock

Mock at system boundaries only:

  • External APIs (payment, email, third-party services)
  • Databases (prefer a real test DB; use an in-memory adapter when that’s impractical)
  • Time / randomness
  • File system (sometimes)

Don’t mock:

  • Your own modules or classes
  • Internal collaborators
  • Anything you control

Test Double Types

Use the naming conventions from tests/unit/test_doubles.py:

  • Stub — returns a hardcoded response, does not record calls. Use when a collaborator must satisfy a type contract but its behaviour is irrelevant to the test.
  • Spy — records how it was called and returns a preprogrammed response. Use when the test needs to assert that a collaborator was called with specific arguments.
  • Mock — returns a response based on conditional logic (a lightweight rule engine). Use sparingly; prefer Stub or Spy.
  • Dummy — satisfies a type constraint but is never invoked. Use when a dependency is required by a constructor but irrelevant to the test.

Shared doubles live in tests/unit/test_doubles.py. Test-specific inline doubles are acceptable when they are too narrow to share.

Designing for Testability with Ports and Adapters

The cleanest way to make code testable at system boundaries is to define a Port (an ABC) and inject it. Tests pass a double that implements the same port; production code passes the real adapter.

from abc import ABC, abstractmethod

# The port — defines what the module needs
class PaymentGatewayPort(ABC):
    @abstractmethod
    def charge(self, amount: int) -> Receipt: ...

# Production adapter — talks to the real payment provider
class StripeAdapter(PaymentGatewayPort):
    def charge(self, amount: int) -> Receipt:
        # real Stripe API call
        ...

# Test stub — satisfies the port, returns hardcoded data
class PaymentGatewayStub(PaymentGatewayPort):
    def charge(self, amount: int) -> Receipt:
        return Receipt(status="confirmed", amount=amount)

# Test spy — records the call so we can assert on it
class PaymentGatewaySpy(PaymentGatewayPort):
    last_charge: int | None = None

    def charge(self, amount: int) -> Receipt:
        self.last_charge = amount
        return Receipt(status="confirmed", amount=amount)

The service under test accepts the port, not the concrete adapter:

class OrderService:
    def __init__(self, payment_gateway: PaymentGatewayPort) -> None:
        self._payment_gateway = payment_gateway

    def checkout(self, cart: Cart) -> Receipt:
        return self._payment_gateway.charge(cart.total)

In tests:

# Unit test: verify behavior, use a stub
def test_checkout_returns_confirmed_receipt():
    service = OrderService(payment_gateway=PaymentGatewayStub())
    result = service.checkout(cart_with_total(100))
    assert result.status == "confirmed"

# Unit test: verify interaction, use a spy
def test_checkout_charges_correct_amount():
    spy = PaymentGatewaySpy()
    service = OrderService(payment_gateway=spy)
    service.checkout(cart_with_total(250))
    assert spy.last_charge == 250

In-Memory Adapters for Integration Tests

For integration tests, prefer a real in-memory adapter over a stub. It exercises real logic (creating, storing, retrieving) without infrastructure:

class InMemoryJokesRepository(JokesRepositoryPort):
    def __init__(self) -> None:
        self._jokes: list[Joke] = []

    def get_jokes(self) -> list[Joke]:
        return self._jokes

    def create_joke(self, joke: Joke) -> Joke:
        self._jokes.append(joke)
        return joke

This is not a mock — it has real behaviour. Use it in integration tests to wire modules together without touching a real database.

SDK-Style Interfaces Over Generic Fetchers

Create specific methods for each external operation instead of one generic method:

# GOOD: each method is independently testable and stubbable
class CredentialAgentPort(ABC):
    @abstractmethod
    def create_offer(self, credential: Credential) -> Offer: ...

    @abstractmethod
    def get_offer_status(self, offer_id: str) -> OfferStatus: ...

# BAD: mocking requires conditional logic inside the double
class CredentialAgentPort(ABC):
    @abstractmethod
    def call(self, endpoint: str, payload: dict[str, object]) -> dict[str, object]: ...

Specific methods mean each stub or spy returns one well-typed shape, with no conditional logic in test setup.

Refactor Candidates

After TDD cycle, look for:

  • Duplication → Extract function/class
  • Long methods → Break into _ prefixed private helpers (keep tests on the public interface)
  • Shallow modules → Combine or deepen
  • Feature envy → Move logic to where data lives
  • Primitive obsession → Introduce value objects as @dataclass(frozen=True)
  • Existing code the new code reveals as problematic

Lint and Type Checks Are Non-Negotiable

All code must be warning- and error-free. Run just lint after every refactor step. This covers both ruff (style, imports, common errors) and basedpyright (type checking). Do not suppress warnings with ignore comments — fix the underlying issue. If you cannot resolve a type error, stop and ask.

Skill python-tdd SKILL.md


name: tdd description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs, mentions “red-green-refactor”, wants integration tests, or asks for test-first development. metadata: author: [mattpocok, berkes] origin: https://github.com/mattpocock/skills

Test-Driven Development

  1. Create a plan for this feature, make sure it follows this skills guide.
  2. Decide if an integration test is needed.
  3. Write a failing integration test.
  4. Or write a failing unit test.
  5. Determine what you expect to fail.
  6. Run tests with just test.
  7. Nothing fails? Something is wrong.
  8. Does the expectation of the failure match the actual failure? No, change the tests.
  9. Write the code to make the test pass.
  10. Run tests with just test.
  11. Refactor the code.
  12. Repeat steps 1-10 until the feature is complete.

Stricly follow other instructions in the projects’ AGENT.md

Test Levels

There are three levels of tests. Use the decision guide below to choose the right level.

Unit tests test a single module in isolation. All collaborators are replaced with test doubles (Stub, Spy, Mock, or Dummy). They are fast, numerous, and specific. See tests.md and mocking.md.

Integration tests test the interaction between modules. Real modules are wired together; only external infrastructure (database, network) may be replaced with adapters. Ports and Adapters make this natural: swap a production adapter for an in-memory one. See tests.md.

End-to-end (e2e) tests test complete user-visible journeys against the running system. They are slow, few, and broad. Run with just test-e2e.

Testing pyramid

Few e2e tests → more integration tests → many unit tests.

Ports and Adapters architecture makes the pyramid work: business logic lives in domain modules that have no infrastructure dependencies, so it can be unit-tested without mocks of infrastructure.

When to write which test

  • New feature → add an e2e test (new file or extend existing file in tests/e2e/)
  • Change behaviour of existing feature → update the existing e2e test, or add one if the change cannot be caught there
  • Change how modules interact → add or update an integration test
  • Change or add detail inside a single module → add or update a unit test
  • User-facing bug that is common/visible → add or update an e2e test
  • Bug in a detail or edge case → add or update a unit test
  • Business logic details → unit tests
  • Happy path, common use → e2e tests

Philosophy

Core principle: Tests should verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn’t.

Good tests are integration-style: they exercise real code paths through public APIs. They describe what the system does, not how it does it. A good test reads like a specification - “user can checkout with valid cart” tells you exactly what capability exists. These tests survive refactors because they don’t care about internal structure.

Bad tests are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn’t changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior.

See tests.md for examples and mocking.md for mocking guidelines.

Anti-Pattern: Horizontal Slices

DO NOT write all tests first, then all implementation. This is “horizontal slicing” - treating RED as “write all tests” and GREEN as “write all code.”

This produces crap tests:

  • Tests written in bulk test imagined behavior, not actual behavior
  • You end up testing the shape of things (data structures, function signatures) rather than user-facing behavior
  • Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
  • You outrun your headlights, committing to test structure before understanding the implementation

Correct approach: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it.

WRONG (horizontal):
  RED:   test1, test2, test3, test4, test5
  GREEN: impl1, impl2, impl3, impl4, impl5

RIGHT (vertical):
  RED→GREEN: test1→impl1
  RED→GREEN: test2→impl2
  RED→GREEN: test3→impl3
  ...

Workflow

1. Planning

When exploring the codebase, use the project’s domain glossary so that test names and interface vocabulary match the project’s language, and respect ADRs in the area you’re touching.

Before writing any code:

  • Confirm with user what interface changes are needed
  • Confirm with user which behaviors to test (prioritize)
  • Identify opportunities for deep modules (small interface, deep implementation)
  • Design interfaces for testability
  • List the behaviors to test (not implementation steps)
  • Get user approval on the plan

Ask: “What should the public interface look like? Which behaviors are most important to test?”

You can’t test everything. Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.

2. Tracer Bullet

Write ONE test that confirms ONE thing about the system:

RED:   Write test for first behavior → test fails
GREEN: Write minimal code to pass → test passes

This is your tracer bullet - proves the path works end-to-end.

3. Incremental Loop

For each remaining behavior:

RED:   Write next test → fails
GREEN: Minimal code to pass → passes

Rules:

  • One test at a time
  • Only enough code to pass current test
  • Don’t anticipate future tests
  • Keep tests focused on observable behavior

4. Refactor

After all tests pass, look for refactor candidates:

  • Extract duplication
  • Deepen modules (move complexity behind simple interfaces)
  • Apply SOLID principles where natural
  • Consider what new code reveals about existing code
  • Run tests after each refactor step

Never refactor while RED. Get to GREEN first.

Checklist Per Cycle

[ ] Test describes behavior, not implementation
[ ] Test uses public interface only
[ ] Test would survive internal refactor
[ ] Code is minimal for this test
[ ] No speculative features added

Good and Bad Tests

Good Tests

Integration-style: Test through real interfaces, not mocks of internal parts.

# GOOD: Tests observable behavior
class TestCheckout:
    def test_user_can_checkout_with_valid_cart(self):
        cart = Cart()
        cart.add(product)
        result = checkout(cart, payment_method)
        assert result.status == "confirmed"

Characteristics:

  • Tests behavior users/callers care about
  • Uses public API only
  • Survives internal refactors
  • Describes WHAT, not HOW
  • One logical assertion per test

Bad Tests

Implementation-detail tests: Coupled to internal structure.

# BAD: Tests implementation details
class TestCheckout:
    def test_checkout_calls_payment_service(self):
        payment_spy = PaymentServiceSpy()
        checkout(cart, payment_spy)
        assert payment_spy.process_called_with == cart.total

Red flags:

  • Mocking internal collaborators
  • Testing private methods (anything prefixed _)
  • Asserting on call counts/order when behavior is what matters
  • Test breaks when refactoring without behavior change
  • Test name describes HOW not WHAT
  • Verifying through external means instead of interface
# BAD: Bypasses interface to verify
def test_create_user_saves_to_database():
    create_user(name="Alice")
    row = db.execute("SELECT * FROM users WHERE name = ?", ["Alice"])
    assert row is not None

# GOOD: Verifies through interface
def test_create_user_makes_user_retrievable():
    user = create_user(name="Alice")
    retrieved = get_user(user.id)
    assert retrieved.name == "Alice"

Testing Pyramid

Keep the pyramid in mind: few e2e tests, more integration tests, many unit tests.

        /\
       /e2e\        ← few, slow, broad
      /------\
     / integr \     ← moderate, test module interactions
    /----------\
   /    unit    \   ← many, fast, specific
  /--------------\

Ports and Adapters make this work. Business logic modules have no infrastructure dependencies, so they can be unit-tested by passing in-memory adapters — no network, no database, no mocks needed. See SKILL.md for when to use each level.

Folder Structure

Unit and integration tests mirror the src/ structure:

src/
  issuer_agent/
    ssi_agent_adapter.py
  metadata/
    metadata_repository.py

tests/
  unit/
    issuer_agent/
      test_ssi_agent_adapter.py
    metadata/
      test_metadata_repository.py
  integration/
    test_metadata_repository.py   # tests module interactions
  e2e/
    test_credential_issuance.py   # one file per business feature
  • tests/unit/ must mirror src/ exactly — one test file per source file, same directory depth.
  • tests/e2e/ has one file per business feature, no subdirectories.

Support Infrastructure

E2e tests (tests/e2e/)

The e2e directory contains support utilities alongside test files: JSON schemas for validating responses, key material for authentication flows, and shared fixtures in conftest.py. Before adding new support files, check what already exists there — reuse schemas, helpers, and fixtures rather than duplicating them.

Unit tests (tests/unit/)

Shared test doubles (Stub, Spy, Mock, Dummy) live in tests/unit/test_doubles.py. Shared fixtures live in tests/unit/conftest.py. Check both before writing new doubles or fixtures. See mocking.md for test double conventions.

Python Code Reference

Automatically generated API documentation for the Python codebase using pdoc.

This documentation covers all modules, classes, and functions in the src/ directory.

View the Python Code Reference

Definition of Done

Done

  • Reviewed and approved by a team member
  • Released to demo environment
  • Test instructions provided

Ready for Review

  • End-to-end test covers new or changed behavior
  • Unit tests added or updated
  • All tests pass
  • CI passes (lint, format, security checks, etc.)
  • Delivery criteria checked off
  • Testable with provided instructions

Glossary

Words, terms and acronyms used in context of the project. Domain language, or ubiquitous language.

A

Achievement
A collection of information about the accomplishment recognized by the Assertion. Formerly known as BadgeClass
AchievementCredential
Specific variation of a Credential. A Credential that follows the Open Badges 3.0 specification for its content.
Award
An Achievement that has been coupled to a user. Plain text. when signed, it becomes a credential. Identified by AwardId
Actor
The entity performing an action. This is currently either a Subject or an Issuer.

B

Backpack
TODO: Define this term. Found in: docs/src/features.md

C

Credential Offer
TODO: Define this term. Found in: OpenAPI specification, docs/src/features.md
Credential
A credential is a set of attributes that are issued by an issuer and can be verified by a verifier.

D

DID
TODO: Define this term. Found in: docs/src/import_in_wallet.md
Deferred Credential
TODO: Define this term. Found in: OpenAPI specification, docs/src/features.md

E

ELM
TODO: Define this term. Found in: README.md, docs/src/features.md. See also: European Learner Model.
European Learner Model
TODO: Define this term. Found in: README.md, docs/src/glossary.md
eSeal
TODO: Define this term. Found in: docs/src/features.md
EC
_E_du_C_redentials. Prefix used in the EduCredentials project, services, and products.
EC-achievements
Service that manages and provides Achievements
EC-authorization
Service that manages access to resources. In context of EC-issuer, it allows us to check what Actor may Issue what Achievement to what Subject.
EC-issuer
This service. The software in this repo. Also known as Educredentials Issuer.

F

G

H

I

Issuer
The entity (usually a person) that signs a verifiable credential. In practice this is either a staff, teacher or other person when a Credential is issued to a Subject. Or it is a student who issues a previously received Credential into their wallet. TODO: Differentiate between the Issuer whose signature is used, and the Person who initialized the Issuance in terminology

J

K

L

M

Mock SSI-Agent
A fake, static version of the SSI-Agent. Stands in for the concrete implementation of the OID4VCI service, in our tests. Offers deterministic, controllable responses that mimick the responses of the SSI-Agent.

N

O

OID4VCI
Abbreviation of OpenID for Verifiable Credentials Issuance.
OID4VCI Agent
The software that is used by an issuer to create and sign a verifiable credential. OID4VCI Agent was previously called “Issuance Agent”.
OID4VP
TODO: Define this term. Found in: docs/src/features.md
Open Badges 3.0 (aka OpenBadgeCredential, OBv3)
Open Badges is an open standard for digital badges. It is a specification for the issuance and verification of digital badges.
OpenID for Verifiable Credential Issuance
OpenID for Verifiable Credentials (OID4VCI) is a specification for the issuance and verification of verifiable credentials. It is based on the OpenID Connect protocol. See OID4VCI for more information.

P

Q

R

Revocation
The process of invalidating a verifiable credential. Since verifiable credentials are decentralized and immutable, they cannot be deleted. Instead, they are revoked by adding them to a revocation - aka status- list).

S

SD-JWT
TODO: Define this term. Found in: docs/src/features.md
SSI-Agent
Concrete implementation of the OID4VCI Agent. Built by Impierce, also known as Unime-Core. We integrate, configure and build this service using [https://github.com/educredentials/ssi-agent-build]. See also Mock SSI-Agent.
Subject
The entity (usually a person) that receives a verifiable credential.
Staff
Deprecated, see Issuer instead. Student
Deprecated, see Subject instead.

T

Teacher
Deprecated, see Issuer instead.
Transaction ID
TODO: Define this term. Found in: OpenAPI specification, docs/src/import_in_wallet.md

U

Unime
TODO: Define this term. Found in: docs/src/features.md, docs/src/import_in_wallet.md

V

Verifiable Credential
A verifiable credential is a tamper-evident credential that has authorship that can be cryptographically verified. It is an open standard. Also known as VCDM (Verifiable Credential Data Model).
Verifier
The entity that verifies a credential. This can be software or a person using this software. Software will typically be a service that returns “valid” or “invalid” for a given credential. A person will typically be a human that can visually inspect a credential and decide if it is valid or not and interpret the content of the verified attributes.
VCDM
TODO: Define this term. Found in: docs/src/glossary.md. See also: Verifiable Credential.

W

Wallet
A digital wallet is a software application that stores verifiable credentials and other digital assets. It is used to get credentials from issuers, store them securely, manage and present these credentials to verifiers.
Wrapper
This EC-issuer service works alongside existing issuer services such as ssi-agent. It orchestrates and integrates the issuance process, manages templates and configurations, and exposes a simple, pragmatic API while delegating actual credential issuance to the underlying issuer service.

X

Y

Z