A documentation of Super Simple Architecture for Web Applications that helps to start right away a project without deeper analysic. This is a collection of decission and conventions to make during development.
Overview
Typically most of the projects requires deeper analysic in order to make a decsision of correct architecture. But don’t cheat ourself – most of work is writting simple CRUD’s or apps with no complex logic!
This architecture driver is intended to provide a set of rules to follow in order to start development without deep investigation. As an application grows and becomes more advanced, the next step should be refactoring into modular monolith application, eventually transitioning to microservices or use anything else like CQRS1. It is easier to simply start your work and then do a refactor later on!
This architecture is all about “reversed Domain-Driven Design”. Instead of event storming, domain discovery, and all of this stuff – simply start from implementation and then see boundaries, models, and correlations in your code directly!
Sebastian Twaróg – founder
Real life example
Imagine you have to build an application that needs 3 clients:
- Single Page Application written in Angular – biggest trafic and very important part of the system as clients uses it and pays for using it,
- “Affiliate program” application – that you want to develop with low cost – PHP Symfony + Twig
- Mobile app.
All of them needs some informations from “somewhere” that we need to implement.
Normally you might consider some advanced concept’s like event storming to discover parts of the system, maybe introduce in some places CQRS, maybe Graphql might be usefull?
Now let’s face reality. Your project has limited resources: inexperienced developers, lack of funds, or you simply want to build an MVP application, earn some money, and then spend more time on sophisticated concepts.
Then this architecture comes for a rescue! Each of clients is called Device in this concept. Create 3 devices:
- RestAPI for SPA,
- Regular Web Application with static html with some templating engine e.g. Twig or Blade,
- Another RestAPI for Mobile APP.
Each of Devices can combine common entities in order to render different view for each needs.
Building blocks
The key concept is to use defined building blocks in the way it is described here.
Devices
This is a directory where each application resides. For example, you might have:
- Web application that renders static HTML.
- Rest API
- CLI.
There SHOULD be dedicated components for each; for example, for web apps, it will be a controller, some validators or transformers, and for CLI – Symfony Command.
This is an entry point to call specific UseCase.
Why do we need this? Each application have different needs and must be optimized for this purpose.
This is also a place where authentication concerns have a place.
Use case
This is the first step of interaction between the device (client) and the application layer. It MUST receive validated DTO (e.g., from Controller or as a CLI Command).
Use cases are used for either data mutation or reading. Many devices can use the same use cases, or each device can have a dedicated one.
It is RECOMMENDED to add extra arguments to the UseCase (beyond a dedicated DTO) to differentiate behavior in case it is shared across multiple device in case UseCase has almost the same logic.
UseCase responsibility is similar to DDD – Application layer. It should orchestrate other services / tasks calls etc.
One UseCase CAN NOT directly call another one!
DTO
Each UseCase MUST have a dedicated DTO (interface). The role of the DTO in this context is to receive and validate primitive types from the client and eventually map them to Value Objects if they are in use.
Result
Each UseCase MUST return a special object: Result, that contains unified information about action result. It helps to decauple UseCases from Devices it is used for. For instance we might want have to return an information of newly created entity ID.
Task
This is a simple logic that belongs to Application layer (using DDD comenclature). It SHOULD receive argument’s and be as generic as possible. Good examples for such tasks are:
- Sending an e-mail,
- Logging some information,
- Sending notifications.
It SHOULD NOT contain any business logic.
Domain logic
Generally domain logic SHOULD be used in the same way how it is desribed in Domain Driven Architecture it means that we MUST follow Object Oriented programming.
It means that our constraint’s and behaviour should be encapsulated by Entities and Value Objects.
The difference in SSA approach is that we don’t care about framwork coupling. Let’s say we are using PHP Symfony Framework with Doctrine ORM – in a regular DDD approach, it should be decoupled. In our case, we don’t care because it will probably be the only way of interacting with the DB. However, we should name objects carefully and meaningfully.
Entity
The most important part of the system is used for reading and writing. There is no space in this architecture to introduce Command Query Responsibility Segregation and we SHOULD NOT do it.
An entity should be rich in behavior and MUST ensure the valid and consistent state of the system. It is not recommended to use auto-increment for IDs; prefer generating a unique one using a creational design pattern, such as Factory.
As we want to use entity for reading as well it is RECOMENDED to use serializer and serialization attributes on methods in order to limit data rendered in Devices for example in Rest Controller.
It CAN trigger event’s if project supports this.
Factory & Builder
Entities ought to be generated utilizing creational design patterns as opposed to direct instantiation. Validation of newly created instances at this stage is essential to guarantee their integrity (each layer should include validation – avoid solely relying on application request validation, for example).
Prefer to use it as concrete implementation.
Repository
The only responsibility of repository is to retrieve or persist Entity from / to data source. In most cases it will be Database but keep in mind it can be as well an external API or even a file.
Each repository MUST have a common contract (needs abstraction and concrete implementation). This is good to start from all methods in one interface and split it when nessesary.
Result object
Repository MUST support mutation, pagination and result object to keep the code consistent. Possible return types:
- void / primitive type (only when something has been changed and we can not track the result),
- Item Object – for one result of Entity,
- Collection Object – for unlimited number of Entities,
- List Object – it should be a result of paginated query.
Value Object
This is exactly the same concept as in DDD approach.
It should ensure data consistency based on primitive types. Value Object MUST be immutable.
Remember to use this only when it is really usefull to avoid not nessesary complexity.
Services
This belongs to the domain layer and should only utilize abstractions of repositories, concrete entities, value objects, etc. It is worthwhile to incorporate more complex logic into services when it doesn’t naturally fit into an entity or contains repetitive logic that can be triggered by multiple Use Cases.
It CAN trigger tasks directly. Task can be trigerred by event’s if needed as well.
Policy
As mentioned earlier, domain logic should be encapsulated in Entities and Services. Simple logic with no external dependencies can be hidden in Entities indeed, and more sophisticated cases including orchestration should be in Services. However, places with repeatable logic that have some external dependencies should be placed in a Policy object / service. It can have external resources but doesn’t have to. A good example is some policy like “Send for production.” There are conditions that need to be met. We need to pass this to our entity before changing the state (trigger it in an entity method), but we also would like to reuse it in some action authorization logic – e.g., using PHP Symfony security voters2.
Events
Generally, it is NOT RECOMMENDED to use events in your application. At the Use Case (application level), prefer direct calls to tasks to maintain clarity. Domain events SHOULD BE generated at the Entity level and then pulled out, dispatched, and handled at the Use Case level. Integration events can be triggered from any part of the system.
Testing
All good software MUST BE well tested. Follow TDD principles – here is the cheatsheet what should be covered:
Component | Type of test | Severity | Notes |
---|---|---|---|
UseCase | Integration & Unit | MUST HAVE | At least happy path for Integration test. |
Device | Application | OPTIONAL | Focus on auth concers. |
Task | Integration & Unit | OPTIONAL | In most cases it will be worth to Integration test. |
Entity | Unit | MUST HAVE | Well tested – business logic. Skip tests for anemic models. |
Value Object | Unit | MUST HAVE | |
Repository | Integration | OPTIONAL | |
Service | Unit | MUST HAVE |
- Command Query Responsibility Segregation – M. Fowler https://martinfowler.com/bliki/CQRS.html ↩︎
- PHP Symfony Voters – https://symfony.com/doc/current/security/voters.html ↩︎