PragmaArch

PragmaArch
Photo by Emile Perron / Unsplash

PragmaArch - Pragmatic Architecture between Clean Code & Clean Architecture

Architecture doesn’t live in the book, it lives in the project.

πŸ“’ Note: This document is a living work. The latest version, updates, and a clear changelog are always available on GitHub: https://github.com/pragmaarch/pragmaarch

Not over-engineered, not just theory, but built to work in real project life. PragmaArch combines the best of Clean Code and Clean Architecture - without losing pragmatism. In software development, two worlds often collide. One values meticulous perfection, clear structures, and strict architectural principles, as Clean Architecture demands. The other favors practical solutions that deliver results fast, avoiding unnecessary over-engineering, as Clean Code often promotes.

But does it really have to be either one or the other?

PragmaArch unites both worlds, adapting to different project sizes and team structures. Especially for MVPs and real business cases, this matters: customers rarely have the resources for the "book solution." They need architectures that deliver value quickly while staying extendable.

All examples are in PHP, but the approach works for any object-oriented language.


Table of Contents

  1. About Dominic Poppe
  2. Why PragmaArch?
    1. What makes PargmaArch different?
  3. Core Principles
  4. Folder Structures
    1. Folder Structure - With Domain Seperation
    2. Folder Structure - Without Domain Seperation
  5. Naming Conventions
  6. Controllers
  7. Services & DTOs
  8. Service Results
  9. Events / CloudEvents
  10. Repositories
  11. Validation
  12. Error Handling
  13. Dependency Injection
  14. Testing Strategy
  15. Diagrams/Charts/Tables
  16. Comparison with Other Architectures
  17. Conclusion

About the Author: Dominic Poppe

My name is Dominic Poppe, born in 1996. I started coding around age 10 with Visual Basic and HTML, tinkering with small scripts and webpages. Over time, I explored Batch scripting and later transitioned to PHP, initially in a procedural style. Early on, I ran a game server hosting business and even built a custom game server panel in PHP from scratch.

Through these experiences, I gained broad technical knowledge, experimenting with mods and PHP backends for games. Around 18-19, I shifted focus to web development, working extensively with JavaScript, and later returned to PHP with a stronger focus on object-oriented programming.

Professionally, I began as a retail clerk, completed my apprenticeship (3 years), then spent 3-4 years in e-commerce, optimizing systems with automation scripts and PHP tools. Eventually, I joined chargecloud as a junior developer, immersing myself in OOP, unit testing, and software best practices, learning through books, colleagues, online courses, and hands-on projects.

I am a self-taught, career-switcher developer-no formal IT studies or degree. PragmaArch reflects my personal approach: it is pragmatic, not perfect, and not a holy scripture. There may be inconsistencies or nuances, but it is designed to provide practical guidance, not academic perfection.

I work extensively with Symfony and Laravel, PHP, MariaDB and MySQL, as well as AWS and cloud hosting. I also maintain a freelance practice (dopo.dev), managing small projects as a small-scale entrepreneur. In my current role at chargecloud, I progressed from junior to medior, senior, and now Tech Lead, contributing significantly to the architecture and future direction of the company.

I value clarity, focus, and practical results over academic jargon. My experience across multiple domains gives me a broad perspective, which is why PragmaArch may feel different from traditional architecture guides. You can treat it as a reference, guardrail, or framework for discussion, and it’s perfectly fine to challenge it. It was partly inspired by questioning conventional Clean Architecture: in many real-world scenarios, it can be overly complex.

PragmaArch represents my idea, mindset, and approach to building maintainable, scalable, and pragmatic software systems.


Why PragmaArch?

On my journey from hobby developer to tech lead, I’ve experienced many kinds of projects: chaotic ones, classic structures, and over-engineered textbook architectures.

PragmaArch, short for Pragmatic Architecture, is a guideline and concept I developed. It’s not a one-size-fits-all solution. There will be cases where strict Clean Architecture makes perfect sense-and others where it doesn’t. Some small projects might even find PragmaArch too heavy.

At its core, PragmaArch is a mindset I’ve applied in personal projects, refining it over time. Its main goal is to maintain system stability and facilitate ongoing development without imposing overly restrictive limits.

The standard approach in many projects is: create a controller, put the logic directly inside it, and go. Requests are not cleanly separated, and data often enters partially unvalidated. PragmaArch addresses this by combining Clean Code and Clean Architecture principles.

Key principles include:

  • Single Responsibility: Keep responsibilities isolated.
  • Layered Architecture: Separate Infrastructure, Application, and Domain layers.
  • Encapsulated Business Logic: Business rules should be independent of infrastructure.
  • Validated Requests: Requests should be their own classes. DTOs built from requests handle validation before passing to services.
  • Explicit Results: Services should return clean Result classes, so controllers or other consumers can handle outcomes explicitly.

In strict Clean Architecture, this is framed in terms of Use Cases. In PragmaArch, services roughly correspond to Use Cases but allow more flexibility. For example, an OrderService could be split into multiple services for creating and deleting orders, each with its own DTO and Result. Alternatively, a pragmatic approach is a single OrderService with create and delete methods, each handling its respective DTO and Result.

The principle of Single Responsibility is highly recommended, but PragmaArch allows pragmatic flexibility when it makes sense.

  • Clean Architecture β†’ robust, SOLID-compliant, decoupled

    • Problem: often too complex for small projects; steep learning curve; overhead from too many layers. Even small features take significantly more time.
  • Clean Code β†’ clear, maintainable, easy to understand

    • Problem: by itself, it doesn’t provide a strategy for scalable layers or domain separation.
  • PragmaArch β†’ the pragmatic blend:

    • Clean Architecture inspirations provide structure, decoupling, and clear responsibilities.
    • Clean Code ensures readability, clear methods, meaningful names, and testability.
    • Pragmatism prevents unnecessary overengineering: only as many layers and classes as needed, no artificial abstractions.

The result is an architecture that is robust, understandable, and productive.

What makes PragmaArch different?

PragmaArch is both an architecture pattern and a personal guideline/mindset. It offers a clear default path to structure your code without the overhead and dogma of full Clean Architecture, while avoiding the chaos of classic MVC.

Key differences:

  • 🧠 Pragmatic over perfect
    Focus on clear boundaries, not theoretical purity. Avoid over-engineering by default.
  • 🧩 Flexible layering
    Domain layers are optional. You can use a domain-based structure or a flat structure depending on the project size.
  • πŸ“₯ DTO-first inputs, Result-first outputs
    Enforces explicit data flow and clear responsibility boundaries.
  • βš™οΈ Slim controllers, thin services, clear separation
    Each class does one thing and nothing else.
  • πŸš€ Easy to adopt incrementally
    You don’t need to refactor your whole codebase at once. Introduce it file by file.
  • πŸ§˜β€β™‚οΈ No "framework within a framework"
    Unlike Clean Architecture, PragmaArch adds minimal boilerplate and fits naturally into existing projects.

Think of it as Clean Architecture Lite - the structure you need, without the ceremony you don't.


Core Principles

This is a pragmatic checklist for developers following the PragmaArch mindset. Think of it as a foundation for clean, maintainable, and scalable code.

🧠 Core Coding Principles

  • SOLID – Each class with a single responsibility, respecting open/closed, Liskov, interface segregation, and dependency inversion.
  • YAGNI (You Ain't Gonna Need It) - Avoid implementing features you don’t need yet.
  • KISS (Keep It Simple, Stupid) - Simplicity over overengineering.
  • DRY (Don't Repeat Yourself) - Reuse code instead of copy-pasting.
  • Micro-optimizations - Optimize only when necessary; prioritize readability.
  • Readable & Descriptive Naming - Always use English; variables, functions, classes: clear, self-explanatory.
  • Avoid Nested Loops/Ifs - Flatten logic using early returns, guard clauses, helper functions.
  • Small, Focused Functions - Each function does one thing well.
  • No Abstract Overhead - Avoid unnecessary abstract classes or interfaces unless clearly beneficial.
  • Consistent Formatting & Indentation - Follow PSR-12 (PHP) or equivalent.
  • Separate Concerns - No multi-purpose methods; logic should be modular.
  • ... read more by looking for "Clean Code"

πŸ—οΈ Architecture & Design

  • Follow Clean Architecture principles - Separate Domain, Application, Infrastructure (if it makes sense).
  • Domain-Driven Design (DDD) - Use bounded contexts, entities, and aggregates where appropriate.
  • Services = Use Cases - Encapsulate business logic, return Result objects.
  • Controllers = Orchestration only - Prefer invokable controllers.
  • Repositories = Data Access - Only CRUD + query-specific methods.
  • Events - Prefer event-driven architecture (CloudEvents when needed).
  • DTOs & Requests - Type-safe, immutable where possible, validated before reaching services.
  • Result Classes - Explicit outputs from services for predictable handling.

🧹 Code Hygiene

  • English-only - Code, comments, and naming.
  • No "magic" values - Use constants or enums instead of literals.
  • Avoid overloaded logic - Split complex logic into helper classes/functions.
  • Clarity > Cleverness - Maintainable code over "smart hacks."
  • No global state - Dependencies explicit via Dependency Injection.

🀝 Folder Structure & PSR Standards

  • PSR-4 Autoloading - Standard namespacing & class mapping.
  • PSR-12 Coding Style - Consistent formatting, spacing, brackets, indentation.
  • Clear Domain Separation - src/Domain/{Entity,Service,DTO,Repository,Controller}
  • Shared Folder - Reusable types or abstractions between domains.
  • Framework-Neutral Design - Domain logic independent of Symfony/Laravel specifics.
  • Feature-Based Organization - Group by business feature when applicable.

πŸ“Œ Pragmatic Best Practices

  • Single Responsibility - Apply everywhere (services, controllers, DTOs).
  • Event-Driven Communication - Keep async processes decoupled and testable.
  • Explicit Returns - Use Result objects or clear types; avoid Booleans.
  • Early Validation - Validate requests/DTOs before business logic execution.
  • CI/CD Friendly Code - Easy to lint, format, test, and deploy automatically.

PragmaArch is a guardrail, not a religion-a pragmatic framework blending Clean Code, Clean Architecture, DDD, PSR standards, and practical principles into maintainable, testable, and scalable code.

Key reminders: clarity, simplicity, and responsibility over cleverness and overengineering.

  • Domain Separation - Mandatory for multiple domains (e.g., Shipping, Billing). Single-domain projects can use simpler structures.
  • Clear Structure & Naming - Each Service, DTO, Event, Result has a defined responsibility.
  • Services/Controllers per Use Case - One Service = one use case. Input = DTO/Request, Output = Result.
  • DTOs - Immutable, strongly typed.
  • Result Handling - Return via Result classes, never Boolean magic.
  • Event Driven - Use CloudEvents for async communication.
  • Entities - Pure data objects, no business logic.
  • Repositories - Mandatory data access layer. Framework-specific implementations allowed; decoupling optional.
  • Dependency Injection - All dependencies via constructor for easier mocking/testing.
  • Testability & Pragmatism - YAGNI, KISS, DRY; implement only what’s needed. Apply Clean Code & Clean Architecture pragmatically.

Folder Structures: Domain Separation or Not?

At the start of a project, it’s important to consider how large it might grow. You can’t always predict this precisely, so PragmaArch offers two approaches based on project size.

For smaller projects, PragmaArch can operate on a single-domain layer level, where service, application, and domain layers are essentially combined. These projects are unlikely to grow significantly or introduce new domains. Any potential new domains are already encapsulated, similar to a microservice perspective. In this scenario, you can use a simplified folder structure without domain separation. You still follow Domain-Driven Design, but each microservice acts as its own domain. The folder structure can be flatter, often following the framework’s default conventions-for example, in Symfony: src/Controller, or in Laravel: app/Http/Controllers. By sticking to these folders and the PragmaArch guidelines, you are on the right track.

For larger projects, such as modular monoliths or more complex systems, you may want to fully embrace Domain-Driven Design while keeping domains encapsulated. Here, PragmaArch recommends a folder structure with domain separation, clearly defining what belongs in each domain. The Shared folder becomes essential in this setup.

For example, consider two domains: Order and Product. The Product domain exists conceptually for Order (since an order contains products), but the Order domain should not directly access or manipulate Product’s internal entities. Instead, use DTOs in the Shared folder. For instance, a ProductDTO in Shared can be used by the Order domain to process orders. This ensures that Order can interact with product data without tightly coupling to Product entities or models. Alternatively, a CreateOrderDTO could be placed in Shared and passed between domains.

The key idea is encapsulation: each domain knows of the other conceptually but cannot instantiate or directly manipulate its internal entities. Think of it as if the domains were running on separate servers, with the Shared folder as the only communication interface. This approach keeps interactions clean, decoupled, and respects domain boundaries.

Comparison (With and without Domain Seperation)

Domain-based structure (recommended for bigger projects) Flat structure (for small projects)
src/Domain/Animal/Controller
src/Domain/Animal/Service
src/Domain/Animal/Entity
src/Domain/Animal/Repository
src/Controller
src/Service
src/Entity
src/Repository
Groups code by business domain Groups code by technical type
Scales well, clearer ownership Easier to start, less boilerplate
Requires discipline to avoid cross-domain leaks Can get messy when project grows
  • βœ… Choose domain-based if you expect growth or multiple developers.
  • βœ… Choose flat if it’s a simple project or proof of concept.

Folder Structure - With Domain Separation

src/
  Domain/
    Animal/
      Entity/
      Service/
      DTO/
      Result/
      Event/
      Repository/
      Controller/
    Billing/
      ...
    Shared/
      ...

Why?

  • Each domain encapsulates its own entities, services, and repositories.
  • Prevents global state and uncontrolled dependencies.
  • Domains remain largely framework-independent.
  • The Shared folder is mandatory for proper DDD implementation. Domains should not directly use code from other domains. Instead, they rely on the Shared folder to exchange data via an abstract layer or interfaces. Treat code from other domains as if it belongs to a separate host, system, or project-this ensures a clean, decoupled, and effective DDD approach.

Example

src/
  Domain/
    Animal/
      Entity/
      	PetEntity.php
      Service/
      	CreatePetService.php
      	DeletePetService.php
      DTO/
      	CreatePetDto.php
      	DeletePetDto.php
      Result/
      	PetCreatedResult.php
      	PetDeletedResult.php
      Event/
      	PetCreatedEvent.php
      Repository/
        PetRepository.php
      Controller/
      	CreatePetController.php
      	DeletePetController.php
    Billing/
      Entity/
      	OrderEntity.php
      Service/
      	CreateOrderService.php
      	CancelOrderService.php
      DTO/
      	CreateOrderDto.php
      	CancelOrderDto.php
      Result/
      	OrderCreatedResult.php
      	OrderCancelledResult.php
      Event/
      	OrderCreatedEvent.php
      Repository/
      	OrderRepository.php
      Controller/
      	CreateOrderController.php
      	CancelOrderController.php

Folder Structure - Without Domain Separation

src/
  Entity/
  Service/
  DTO/
  Result/
  Event/
  Repository/
  Controller/

Why?

  • Less overhead for small to medium projects or MVPs.
  • Clear separation of business logic and infrastructure is still maintained.
  • Following the framework’s folder conventions (Laravel, Symfony) is often enough, as long as all other aspects of PragmaArch are followed.

Naming Conventions

PragmaArch provides clear naming conventions, though you can adapt them if needed. It’s not a rigid rulebook - its purpose is as a guardrail guideline to unify and standardize project structure. Also follow: https://www.php-fig.org/bylaws/psr-naming-conventions/.

For example, services follow {Verb}{Entity}Service (e.g., CreatePetService). Interfaces, events, repositories, and entities follow similarly clear patterns: PetCreatedEvent, PetRepository, PetEntity, etc. The goal is consistency and readability, making it easier for new developers to navigate the codebase.

Naming also reinforces the Single Responsibility Principle from SOLID and Clean Architecture. Clear, consistent names strengthen the separation of responsibilities. While folder structure helps, I advocate for class-level naming clarity. Avoid overengineering with deep namespace nesting or excessive renaming. Keep it simple.

By following these conventions, implementing a feature such as creating a pet becomes much easier to navigate. For example, searching for CreatePet in your IDE will immediately surface the Controller, Service, DTO, and other components tied to the "create" action. This makes related code highly discoverable and navigation across the codebase more intuitive. Alternatively, searching for just Pet will reveal all components associated with that entity/object.

  • Entities: {Name}Entity.php β†’ PetEntity
  • Services: {Verb}{Name}Service.php β†’ CreatePetService
  • Controllers: {Verb}{Name}Controller.php β†’ CreatePetController
  • DTOs: {Verb}{Name}Dto.php β†’ CreatePetDto
  • Requests: {Verb}{Name}Request.php β†’ CreatePetRequest
  • Results: {Verb}{Name}Result.php β†’ PetCreatedResult
  • Events: {Name}{Verb}Event.php β†’ PetCreatedEvent
  • Interfaces: {Name}Interface.php β†’ PetRepositoryInterface
  • Repositories: {Name}Repository.php β†’ PetRepository

Consistent naming improves readability and simplifies onboarding for new developers.


Controllers

Controllers in PragmaArch follow the Single Responsibility Principle, just like everything else. Each controller has one clear responsibility. For example, a controller responsible for creating a pet would be named CreatePetController. It has a single task and doesn’t handle unrelated actions.

Some might argue that strict single-action controllers are overengineered. Technically, a PetController handling create, edit, and delete could work. However, in PragmaArch, Single Responsibility at the controller level is enforced strictly. This makes controllers predictable, testable, mockable, and less error-prone.

Controllers should also follow the callable pattern using the __invoke method. Typically, the __invoke method receives the request, which the controller then processes. This keeps each controller focused on one action and simplifies routing and testing.

With this approach, a CreatePetController has a single __invoke method that receives the request and delegates to a service. Controllers remain slim, simple, and highly effective.

Controllers should not contain business logic. Their sole responsibility is to receive requests and return results. The only logic allowed is validation, provided it’s not already handled in the Request DTO, Middleware or Service.

Key points:

  • Single Responsibility: One controller = one endpoint.
  • No Logic: Controllers orchestrate services only.
  • Callable via __invoke: Lightweight and testable.
class CreatePetController {
    public function __construct(private readonly CreatePetService $createPetService) {}

    public function __invoke(Request $request) {
        $createPetRequest = new CreatePetRequest($request);
        $result = $this->createPetService->create($createPetRequest);
        
        return new JsonResponse($result->getMessage());
    }
}

Services & DTOs

Traditionally, software used the Model-View-Controller (MVC) pattern, where controllers often contained business logic, managed models, and rendered templates. This "old-school" approach is behind us. Modern practices, guided by SOLID principles and community best practices, emphasize clear separation of concerns and maintainable, testable code.

In PragmaArch, this separation is achieved through Services. Instead of strictly following Clean Architecture’s "Use Cases," PragmaArch uses Services to encapsulate business logic. A service’s responsibility is to receive input, execute business logic, and return output.

Example: CreatePetService

  • Receives a CreatePetDTO from a controller.
  • Uses a repository (auto-injected) to create and persist a Pet entity.
  • Optionally uses builders or other classes for complex logic.
  • Returns a well-defined result-for example, PetCreatedResult or PetNameAlreadyUsedResult.

Benefits

  • Single Responsibility: Each service has a clear, focused task.
  • Testability: Easily unit tested in isolation.
  • Reusability: Can be called by multiple controllers or other services without duplicating logic.
  • Clear outcomes: Result classes remove ambiguity, avoiding "magic constants" or guesswork.

While Result classes are recommended for maintainability, smaller projects can adapt pragmatically, for example, a simple PetResult may suffice. The key is consistency: choose an approach and apply it throughout the project.

Key Principles

  • Input: DTOs for complex input needing validation; well-defined arguments for simple, stable inputs (YAGNI).
  • Output: Explicit Result classes.
  • Services encapsulate business logic and remain framework-independent.
  • SOLID + Clean Architecture: Services are focused, testable, reusable, and decoupled from the framework.

In short, PragmaArch Services act as focused business logic handlers, ensuring code is maintainable, testable, and easy to understand while flexible enough for different project sizes and complexity levels.

class CreatePetDto {
    public function __construct(
        public readonly string $name,
        public readonly float $size,
        public readonly float $price
    ) {}
}

class CreatePetService {
    public function __construct(private PetRepository $petRepository) {}

    public function create(CreatePetDto $petDto): PetCreatedResult|PetNameAlreadyUsedResult {
        if ($this->petRepository->existsByName($petDto->name)) {
            return new PetNameAlreadyUsedResult();
        }

        $pet = new PetEntity($petDto->name, $petDto->size, $petDto->price);
        $this->petRepository->save($pet);

        return new PetCreatedResult($pet);
    }
}

Service Results / Result classes

Result classes define a clear, expected return for each use case. While you could use booleans or other "magic" values, these lack system-wide clarity. Well-defined Result classes, each tied to a single responsibility or use case, provide consistent and transparent return values: it’s always clear what is returned, in what form, and why.

Enums can complement or even replace simple result classes for smaller or simpler services, providing type-safe, easily readable outcome statuses.

  • Purpose: Clearly communicate success or failure.
  • Advantages over booleans or exceptions: Explicit, traceable, and testable.
  • Use in Services: Services (e.g., CreatePetService) receive a DTO, execute business logic, persist entities, and return a Result object describing the outcome.
  • Use in Services: Services (e.g., CreatePetService) receive a DTO, execute business logic, persist entities, and return either:
    • A dedicated Result class (e.g., PetCreatedResult, PetNameAlreadyUsedResult)
    • Or a Result object using an enum to indicate status

ResultInterface

interface ResultInterface {
    public function getMessage(): ?string;
}

Example (Result class)

final class PetCreatedResult implements ResultInterface {
    public function __construct(public readonly PetEntity $pet) {}
    public function getMessage(): ?string { return 'Pet created.'; }
}

final class PetNameAlreadyUsedResult implements ResultInterface {
    public function getMessage(): ?string { return 'Pet name is already used.'; }
}

Example (Enum class)

enum PetCreationStatus: string {
    case SUCCESS = 'success';
    case NAME_ALREADY_USED = 'name_already_used';
    case INVALID_DATA = 'invalid_data';
}

Events / CloudEvents

Events are a core principle in PragmaArch. Unlike many other architectural guidelines, PragmaArch emphasizes event-driven design as a foundation for maintainable, scalable, and future-proof systems. Modern applications increasingly handle asynchronous operations via events.

A typical flow might look like this:

  • An API endpoint receives a request asynchronously.
  • The request is queued (e.g., RabbitMQ, Kafka).
  • A worker processes the request, creates the entity, and persists it in the database.
  • The API responds immediately, often with an HTTP status like 202 Accepted.
  • Once the entity is created, a PetCreatedEvent is dispatched.
  • Multiple listeners consume this event, triggering actions like sending emails, notifications, or other side effects.

Event-driven design is highly effective in many cases (roughly 60-70%). It simplifies asynchronous workflows, allows decoupling, and scales well without complex orchestration.

Limitations

  • Strictly ordered processes (e.g., create a contract β†’ credit check β†’ notify supplier) may require an orchestrator or workflow engine.
  • Events dispatched to multiple listeners are inherently unordered; use them for decoupled, independent reactions (notifications, emails, push messages).

PragmaArch strongly recommends the CloudEvents specification, a community-driven standard for event formats. This ensures interoperability and consistent, well-structured events across systems.

Typical PragmaArch Implementation

  • A clean Event Interface
  • Concrete events (e.g., PetCreatedEvent)
  • Generic CloudEvent wrappers
  • A Dispatcher to send events asynchronously
  • Listeners that consume events in other system parts

Framework-specific implementations (Laravel, Symfony) are fully compatible. PragmaArch concepts integrate with your framework’s event system. The goal is decoupled, testable, and scalable event-driven design.

Event-Driven Architecture Pattern:
Request β†’ Controller β†’ Event β†’ Dispatcher β†’ Listener β†’ Service

Keynotes

Events are extremely valuable and should always be used intentionally in modern architectures.

EventInterface

interface EventInterface {
    public function getType(): string;
    public function toArray(): array;
    public function getId(): string;
}

CloudEvent

class CloudEvent implements \JsonSerializable
{
    public function __construct(
        private readonly Event $event,
        private readonly ?string $source = null,
        private readonly ?string $subject = null,
        private readonly \DateTime $time = new \DateTime()
    ) {
    }

    public function toArray(): array
    {
        return array_filter(
            [
                'id' => $this->event->getId(),
                'subject' => $this->subject,
                'source' => $this->source,
                'type' => $this->event->getType(),
                'specversion' => '1.0',
                'time' => $this->time->format(\DateTime::ISO8601),
                'datacontenttype' => 'application/json',
                'data' => $this->event->toArray(),
            ]
        );
    }
    
    public function getEvent(): Event
    {
        return $this->event;
    }

    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
    
    public static function create(
        Event $event, 
        ?string $source = null,
        ?string $subject = null,
        ?\DateTime $time = null
    ): self
    {
        return new CloudEvent(
            $event,
            $source,
            $subject,
            $time ?? new \DateTime()
        );
    }
}

AbstractEventDispatcher

abstract class AbstractEventDispatcher
{
    private EventInterface $event;
    private ?string $source;
    private ?string $subject;
    private \DateTime $time;

    //Do magic, for example, now send $event to all listeners
    //or use $cloudEvent for system wide-processes
    //or now send the event to your framework relevant dispatcher etc. pp.
    abstract protected function send(CloudEvent $cloudEvent): void;

    public function dispatch(
        EventInterface $event,
        ?string $source = null,
        ?string $subject = null,
        ?\DateTime $time = null
    ) {
        $cloudEvent = CloudEvent::create($event, $source, $subject, $time);
        
        $this->send($cloudEvent);
    }
}

Example

class PetCreatedEvent implements EventInterface
{
    public function __construct(
        private readonly string $eventId,
        private readonly PetEntity $petEntity
    ) {
    }

    public function getType(): string
    {
        return 'PET_CREATED';
    }

    public function getId(): string
    {
        return $this->eventId;
    }

    public function toArray(): array
    {
        return [
            'id' => $this->petEntity->id,
            'name' => $this->petEntity->name,
        ];
    }
}


$petCreatedEvent = new PetCreatedEvent('test_event_123', $petEntity);

$eventDispatcher = new EventDispatcher();
$eventDispatcher->dispatch($petCreatedEvent);

Repositories

PragmaArch emphasizes a clear separation between Entities and Repositories. In many older projects, patterns like Eloquent or IDORM with Paris embed queries directly in models. Modern best practices favor a clean separation of concerns.

Key Principles

  • Entities act as simple, typed data holders (similar to DTOs). They encapsulate data and provide getters/setters but contain no business or persistence logic.
  • Repositories handle loading, saving, updating, and deleting entities.

Example

  • PetRepository may include findById($id) returning a PetEntity.
  • It can also include save() and delete() to persist or remove entities.

In frameworks like Symfony, an EntityManager often handles persistence. Repositories act as query wrappers, while entities remain simple DTO-like objects. PragmaArch is flexible: repositories can combine query logic and persistence without extra abstraction layers.

Philosophy

Repositories provide a dedicated infrastructure layer for interacting with storage. You don’t need additional layers unless your project demands extreme flexibility. If the database choice is stable (e.g., MariaDB, MySQL, Postgres), further abstraction is usually unnecessary.

In short, PragmaArch repositories handle all typical CRUD operations and keep the architecture clean, practical, and aligned with real-world project needs.

Summary

  • Mandatory layer to separate data access from business logic.
  • Can be framework-near (e.g., using Doctrine or Eloquent directly).
  • Optional: introduce a separate Infrastructure layer for stronger decoupling.

Mandatory methods (if no separate Infrastructure layer exists):

  • save
  • delete

Additional methods depend on specific queries, e.g.:

  • findById
  • getAllOlderThan(...)

PragmaArch intentionally allows deviation from strict Clean Architecture: the repository knows how to access data. Additional infrastructure layers are optional, not mandatory.

interface RepositoryInterface {
    public function save(Entity $entity): void;
    public function delete(Entity $entity): void;
}

class PetRepository implements RepositoryInterface {
    public function save(PetEntity $pet): void {}
    public function delete(PetEntity $pet): void {}
    public function findById(string $id): ?PetEntity {}
}

Validation

PragmaArch does not enforce strict rules, interfaces, or examples for validation. The guideline is simple: validate as early as possible, ideally before the service layer is invoked.

Key Points

  • Controller-level validation: Validate in the controller, just before passing data to the service.
  • DTO or Request validation: Ideally, the DTO or Request object performs validation. This ensures data is correct and complete before reaching business logic.
  • Service-level validation: Use as a last resort with a dedicated validator (e.g., PetCreateValidator). Doing it here is possible but considered less clean and indicates late validation.

Validating early-at the controller or DTO level-reduces coupling and keeps responsibilities clear. Modern frameworks like Symfony support this approach via request validation and assertions, allowing errors to be caught before the service executes.

Summary

  • Performed before the service call (controller preferred). Otherwise, the DTO can validate, or the service can use a DI validator.
  • Advantage: reusable validation logic and clear separation of concerns.

In short, PragmaArch encourages early, framework-aligned validation, keeping services focused purely on business logic.


Error Handling

Use Result for expected errors, exceptions only for unexpected ones.

In many projects - my own early ones included-exceptions are often used for control flow, such as throwing an exception when a name is already taken. This is considered bad practice.

PragmaArch defines a clean approach to error handling:

  • Expected errors: Use Service Result classes to represent predictable outcomes. For example, a PetNameAlreadyUsedResult clearly communicates a handled, expected error without throwing an exception.
  • Unexpected or critical errors: Only truly unexpected or critical issues should raise exceptions.

Key principle: do not use exceptions to simulate normal outcomes. Exceptions are reserved for unanticipated problems, while predictable results are handled explicitly via result objects. This makes the system more robust, testable, and maintainable.

  • Expected errors β†’ Result classes
  • Unexpected errors β†’ Exceptions, handled globally

Principle from Clean Code: clarity over flow, no hidden errors.


Dependency Injection

PragmaArch strongly recommends constructor-based Dependency Injection (DI). This ensures components are fully decoupled, making them highly testable and mockable.

Key Points

  • Avoid static methods for general use. Patterns like Static Factory or Singleton are acceptable in certain cases, but should not be default.
  • Leverage framework DI: Modern frameworks, like Symfony, provide autowiring and dependency injection. Using DI makes code predictable, modular, and easy to maintain.

Summary

  • All dependencies are provided via the constructor.
  • No static calls (except for specific patterns like factories or singletons).
  • Facilitates mocking, testing, and clean architecture compliance.

Testing Strategy

PragmaArch emphasizes testable and mockable code by design, thanks to its conventions and architectural rules. The testing strategy is structured around three types of tests:

  1. Unit Tests

    • Focus on services, entities, and business logic classes.
    • Ensure core logic works in isolation from frameworks or infrastructure.
  2. Integration Tests

    • Focus on repositories and infrastructure components.
    • Validate interactions with databases, APIs, or other external systems.
  3. Functional Tests

    • End-to-end, use-case-driven tests.
    • Example: creating an animal via a controller, covering the full flow from request to persistence.
    • Similar to integration tests but focused on complete features or use cases.

Guiding Principles

  • Always test business logic first, then framework-heavy or integration-dependent components.
  • Organize tests by type in separate folders: tests/Unit, tests/Integration, tests/Functional.
    • This ensures clear separation and allows test frameworks to run them efficiently in parallel.

Summary

  • Unit Tests: Services, Entities
  • Integration Tests: Repositories, Infrastructure
  • Functional Tests: Controller endpoints

Principle: test business logic first, framework-heavy components afterward.


Diagrams / Charts / Tables

Decision Flow Charts (Should I use PragmaArch?)

           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚ Starting a new project? β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 No      β”‚ Yes
                         β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚ Is long-term scalability &      β”‚
        β”‚ maintainability important?      β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           No   β”‚        Yes
                β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ Is this a quick prototype β”‚
   β”‚ / throwaway app?          β”‚
   β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    Yes  β”‚          No
         β”‚
   β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ Don't use β”‚      β”‚ Do you work   β”‚
   β”‚ PragmaArchβ”‚      β”‚ alone?        β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
                          Yes β”‚ No
                              β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚ Comfortable with      β”‚
                    β”‚ some initial overhead?β”‚
                    β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜
                          No            Yes
                          β”‚             β”‚
                     β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
                     β”‚ Don't use  β”‚ β”‚ Use      β”‚
                     β”‚ PragmaArch β”‚ β”‚PragmaArchβ”‚
                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • Avoid over-engineering for throwaway apps
  • OK to adopt later (incrementally refactor)
  • Best for multi-dev, mid/long-term projects

Architectural Flow

Layer Components / Responsibilities
UI Layer Views / Components, Controllers
Application Layer Use Cases, Service Interfaces
Domain Layer Entities, Value Objects, Domain Logic
Infrastructure Layer DB Adapters, API Clients, Repositories

Process Flow

Step Description
1 Define feature requirements
2 Design domain model (Entities, Value Objects)
3 Add domain logic / rules
4 Create use cases in Application Layer
5 Define interfaces / ports for infrastructure
6 Implement adapters in Infrastructure Layer
7 Wire use case into UI (Controller, View)
8 Write tests for domain, use cases, and UI

Layer Overview

A typical layer flow in PragmaArch looks like this:

+--------------------+       +--------------------+       +------------------+       +-------------------+
|    Request         | --->  |    Controller      | --->  |     Service      | --->  |    Repository     |
+--------------------+       +--------------------+       +------------------+       +-------------------+
          ^                                                        |                            |
          -                                                        v                            v
          -                                               +-----------------+         +--------------------+
          ----------------------------------------------  |      Result     |         |   Infrastructure   |
                                                          +-----------------+         +--------------------+

  • Request: The incoming request, optionally pre-validated.
  • Controller: Validates the request if needed and orchestrates the service call.
  • Service: Executes business logic, optionally calling additional domain logic or repositories.
  • Repository / Infrastructure: Handles persistence or external integrations.
  • Service Result: Encapsulates the outcome (success, failure, messages).
  • Controller Response: Converts the result into an HTTP or API response for the user.

This structure keeps responsibilities clear, testable, and maintainable.

Reader's Guide: Controller orchestrates, Service encapsulates the use case, Repository handles data access, and Result makes returns explicit.

Event Flow

Events in PragmaArch follow a standard event-driven pattern:

[Service] --(EventInterface)--> [EventDispatcher] --> [Listener]
                                      |
                                      v
                                  CloudEvent
  • Service / Business Logic: Triggers events when certain actions occur.
  • CloudEvent: Standardized event object that decouples sender and receivers.
  • Listeners: React to events, performing actions like notifications, emails, or further processing.

This flow aligns with modern frameworks while maintaining clean, decoupled architecture.

Reader's Guide: For small projects, Event + Listener is sufficient; for scaling, CloudEvents is recommended for standardized, interoperable communication.

Domain vs. Single-Domain-Structure

Multi-Domain:                               Single-Domain:
Domain/Animal/Service/...                   Service/
Domain/Billing/Service/...                  DTO/
...                                         Repository/

Decision Guide: Use Multi-Domain for 2+ clearly separated bounded contexts or teams with > five developers. Use Single-Domain for MVPs or small teams.


Comparison with Other Architectures

Topic PragmaArch Clean Architecture Classic Structure
Domain Separation Clear, pragmatic Strict, complex Usually none
Naming Explicit, consistent Strict, sometimes generic Inconsistent
Services Per use case + Result Use Cases (Interactors) Often mixed in controller
Events CloudEvents or simple Possible, not standardized Rare
Complexity Medium High Low
Testability High High Medium
Time-to-Value Fast Slow Fast, but fragile

Conclusion

PragmaArch = Pragmatism + Structure + Readability

  • Suitable for MVPs and large projects
  • Domain-centered, SOLID-compliant
  • Services, Request/DTOs, Results β†’ clear flows
  • Event-driven, Repositories decouple data access
  • Testability & maintainability in focus

PragmaArch is not a dogma, but a toolbox: take the best from Clean Code & Clean Architecture - applied according to project size and team maturity.


🚫 When NOT to Use PragmaArch

PragmaArch is not a silver bullet.

Avoid it if:

  • πŸ“ Tiny throwaway scripts
    CLI scripts, one-off cron jobs, or single-page tools don’t benefit from this structure.

  • ⚑ Ultra-rapid prototypes
    If you’re validating an idea in a day or two, just write fast and dirty code.

  • 🧠 Teams already fully invested in Clean Architecture
    If your org already enforces full Clean Architecture with DDD, PragmaArch adds no value.

  • πŸ§β€β™‚οΈ Single-function microservices
    Very small services with one or two endpoints don’t need multiple layers.

In short: don’t use PragmaArch where the structure costs more than it saves.


FAQ

❓ Why not just use Clean Architecture?

Clean Architecture is great in theory but often too rigid in practice. It introduces layers and abstractions that add overhead without always bringing real value. PragmaArch keeps the spirit of Clean Architecture (clear boundaries, separation of concerns) but strips away unnecessary ceremony. It's built for developers who want clarity without drowning in boilerplate.

❓ Isn’t this just MVC with DTOs?

Not exactly. Classic MVC often mixes logic across controllers, models, and views. PragmaArch enforces stricter boundaries:

  • Controllers handle only HTTP concerns.
  • Services contain business logic (the "use cases").
  • DTOs keep input/output data clean.
  • Results make success/failure explicit.

So while it may look like MVC at first glance, those additional layers (Services, Repositories, Results) ensure cleaner responsibilities, easier testing, and better scalability.

❓ Do I have to rewrite my whole project to use PragmaArch?

No. You can adopt it gradually:

  1. Start with DTOs for request/response handling.
  2. Move logic out of controllers into Services.
  3. Introduce Results and Events when complexity grows.

This evolutionary approach makes PragmaArch friendly for legacy projects.

❓ When should I use domain-based folder separation?

Domain separation is useful for large, long-lived projects with multiple developers working in parallel. For smaller apps, it’s perfectly fine to stick with a flat structure. πŸ‘‰ Rule of thumb: If you feel lost or overwhelmed scrolling through your project folder, it's time to separate by domain.

❓ How does PragmaArch work with frameworks like Symfony or Laravel?

PragmaArch is framework-agnostic. You can implement its concepts in any PHP framework. For example:

  • In Symfony, map your HTTP requests to DTOs with Request objects.
  • In Laravel, wrap your Form Requests or Jobs with DTOs and Services.

You don't need to fight the frameworkβ€”just layer PragmaArch principles on top.

❓ How do I test in PragmaArch?

  • Unit tests focus on Services (business logic).
  • Integration tests ensure Repositories and infrastructure work as expected.
  • End-to-end tests validate controllers and routes.

Because concerns are separated, testing is simpler and faster.

❓ Is PragmaArch suitable for microservices?

Yes. The boundaries enforced by PragmaArch (DTOs, Services, Results, Events) make it easier to split features into separate services when the time comes. You can start monolithic, then evolve into microservices without rewriting core logic.

❓ What if my team isn’t disciplined enough to follow these rules?

That’s exactly why PragmaArch exists. By providing lightweight conventions (DTOs, Services, Results), the architecture nudges developers toward good practices without requiring them to memorize a 400-page book or follow dogmatic rules.


🚧 Work in Progress (WIP)

I'm currently working on practical framework examples to make PragmaArch even easier to adopt in real-world projects. Planned additions include:

Laravel examples:

  • How to map Form Requests into DTOs.
  • How to structure Services and Repositories in a typical Laravel app.
  • How to return Results cleanly in controllers.

Symfony examples:

  • Using Symfony Request objects with DTOs.
  • Best practices for wiring Services and Repositories.
  • Integrating Results with Symfony’s Response handling.

These examples will show end-to-end flows (Controller β†’ DTO β†’ Service β†’ Repository β†’ Result β†’ Response) in both frameworks.

Stay tuned - this section will grow with ready-to-use snippets and folder structures soon.


πŸ“’ Keep in mind: This guide will continue to evolve. For the most recent updates and a detailed changelog, visit: https://github.com/pragmaarch/pragmaarch

References

  • Robert C. Martin – Clean Code
  • Robert C. Martin – Clean Architecture
  • CloudEvents Spec – https://cloudevents.io
  • Domain-Driven Design (Eric Evans)

Copyright by Dominic Poppe.