Explains why Dotkernel keeps business logic in dedicated Service classes instead of in request handlers, entities, or repositories, and lists the advantages of that separation over the alternatives.
Every Dotkernel module splits its code by responsibility, and business logic gets a directory of its own:
src/User/src/
├── Handler/ # PSR-15 request handlers — HTTP in, HTTP out
├── InputFilter/ # validation of incoming data
├── Service/ # business logic
└── ConfigProvider.php
Entities and repositories live alongside them (in the Core submodule, once you have one), so a single feature is spread across four narrow layers rather than one wide class:
A handler stays thin. It declares the Service it needs as a constructor dependency, delegates, and returns:
class PatchUserActivateHandler extends AbstractHandler
{
#[Inject(MailService::class, UserServiceInterface::class, RendererInterface::class)]
public function __construct(
protected MailService $mailService,
protected UserServiceInterface $userService,
protected RendererInterface $renderer,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$user = $request->getAttribute(User::class);
if ($user->isActive()) {
throw ConflictException::create(Message::USER_ALREADY_ACTIVATED);
}
$this->userService->activateUser($user);
$this->mailService->sendActivationMail(
$user,
$this->renderer->render('user::activate', ['user' => $user])
);
return $this->infoResponse(Message::USER_ACTIVATED);
}
}
The Service holds the rules and the orchestration, and it is the only layer that knows the sequence of steps a business operation requires:
public function deleteUser(User $user): User
{
$this->revokeTokens($user);
return $this->anonymizeUser($user);
}
Note that separating Services does not mean emptying your entities.
UserService::activateUser() calls $user->activate() and then hands the entity to the repository.
State changes stay on the entity, while the surrounding workflow (revoking tokens, anonymizing, saving, notifying) stays in the Service.
Each Service is registered in its module's ConfigProvider and resolved through the DI container, and Dotkernel ships each one behind an interface (UserServiceInterface, AdminServiceInterface, SettingServiceInterface, etc.).
Consumers depend on the interface, so the implementation can be replaced without touching a single handler.
You can use your own subclass, a decorator that adds caching or logging, or a fake in a test.
MailService and IpService live in the Core submodule and are consumed identically by API, Admin, and Queue, so a fix lands once instead of once per repository.…ServiceInterface, you override behavior by rebinding one entry in a ConfigProvider.Let's consider the alternative architectures and what each one costs you. These are the places where business logic ends up when it isn't given a layer of its own.
| Alternative | What it looks like | Why Dotkernel avoids it |
|---|---|---|
| Logic in the request handler ("fat controller") | The handler validates, queries, applies rules, saves, and sends mail | Reachable only over HTTP; needs a full request to test; the same rule gets copy-pasted into the next handler that needs it |
| Logic in the entity ("fat model") | $user->activate() also revokes tokens and sends the activation email |
Entities are hydrated by Doctrine, not by the DI container, so they can't cleanly receive a mailer or repository; persistence concerns end up tangled with workflows |
| Logic in the repository | Repository methods orchestrate several entities and trigger side effects | Repositories are about retrieving and persisting; mixing in orchestration makes them application-specific and no longer safe to share through Core |
| Logic in middleware | Business rules enforced by pipeline middleware | Only fires on HTTP paths, depends on pipeline ordering, and is invisible to CLI and Queue execution |
| Logic duplicated per application | API and Admin each implement user activation their own way | Every bug is fixed twice, and the two implementations drift apart over time |
| Static helpers / utility classes | UserHelper::activate($user) |
Nothing to inject and nothing to substitute, so tests and alternative implementations both have to reach around the helper |
Every one of these alternatives works right up until a second caller, a second application, or a second developer arrives — and then the logic has to be found, untangled, and copied. Dotkernel gives business logic its own layer from the start, so it is written once, injected wherever it is required, tested in isolation, and shared through Core instead of duplicated. Adding a Queue consumer or an Admin screen on top of existing rules becomes a matter of calling a Service, not of reimplementing it.
Q: Why not just put the business logic in the request handler?
A: Because logic inside a handler is only reachable over HTTP. A Service can be called from a handler, a CLI command, or a Queue consumer, and it can be unit tested without constructing a request.
Q: Does using Services mean my entities should have no behaviour?
A: No. State changes intrinsic to the entity stay on the entity — UserService::activateUser() calls $user->activate(). The Service owns the surrounding workflow: saving, revoking tokens, and sending notifications.
Q: What's the difference between a Service and a Repository?
A: A repository retrieves and persists entities. A Service applies business rules and orchestrates the steps of an operation, calling one or more repositories to do so.
Q: Why does every Service have an interface?
A: So consumers depend on …ServiceInterface rather than a concrete class. You can swap in your own implementation, decorate it, or mock it in tests by rebinding one entry in a ConfigProvider.
Q: Where should a Service live — in the application or in Core?
A: In Core if more than one application needs it, as with MailService and IpService. Keep it in the application if only that application uses it.