Services
Services form the business logic layer in Melodic. They sit between controllers and data access, encapsulating domain logic, validation, and orchestration. Controllers depend on service interfaces, and services use Query and Command objects to interact with the database through DbContext.
Controller → Service → Query/Command → DbContext → Database
The Service Base Class
Melodic\Service\Service provides a foundation for your service classes with database context management built in.
<?php
declare(strict_types=1);
namespace Melodic\Service;
use Melodic\Data\DbContextInterface;
class Service
{
public function __construct(
protected readonly DbContextInterface $context,
protected readonly ?DbContextInterface $readOnlyContext = null,
) {
}
public function __destruct()
{
// Cleanup/disposal hook — override in subclasses as needed.
}
protected function getContext(): DbContextInterface
{
return $this->context;
}
protected function getReadOnlyContext(): DbContextInterface
{
return $this->readOnlyContext ?? $this->context;
}
}
Constructor
The constructor accepts two parameters, both auto-wired by the DI container:
| Parameter | Type | Purpose |
|---|---|---|
$context |
DbContextInterface |
Primary (read-write) database context — required |
$readOnlyContext |
?DbContextInterface |
Optional read-only context for read replicas — defaults to null |
Accessor Methods
getContext()— returns the write-capable database context ($this->context)getReadOnlyContext()— returns the read-only context if one was provided, otherwise falls back to the write context
Tip: You can access the context directly via $this->context since it is a protected property, or use the getContext() accessor — both work identically.
Destructor
The base class provides an empty __destruct() method you can override in subclasses for cleanup tasks like closing connections or releasing resources.
The Service Layer Pattern
The service layer enforces a clear separation of responsibilities:
- Controllers handle HTTP concerns — reading request data, calling service methods, and returning responses
- Services encapsulate business logic — validation, orchestration, and data access coordination
- Queries and Commands own the SQL and execute through DbContext
Important: Services should never return Response objects. That is the controller's responsibility. Services return domain models, scalar values, or throw exceptions — the controller decides how to represent the result as HTTP.
This layering keeps each class focused and testable. You can unit test services without HTTP concerns and swap controller implementations without touching business logic.
Creating a Service Interface
Define an interface that describes the operations your service provides. This is the contract that controllers depend on.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\UserModel;
interface UserServiceInterface
{
/** @return UserModel[] */
public function getAll(): array;
public function getById(int $id): ?UserModel;
public function create(string $username, string $email): int;
public function update(int $id, string $email): bool;
public function delete(int $id): bool;
}
Why interfaces? Binding controllers to interfaces rather than concrete classes lets you swap implementations without changing any controller code. This is especially useful for testing (mock implementations) and for evolving your architecture over time.
Implementing a Service
Extend Service, implement your interface, and use Query/Command objects in each method. The DI container auto-wires DbContextInterface into the base class constructor.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Commands\CreateUserCommand;
use App\Commands\DeleteUserCommand;
use App\Commands\UpdateUserEmailCommand;
use App\Models\UserModel;
use App\Queries\GetAllUsersQuery;
use App\Queries\GetUserByIdQuery;
use Melodic\Service\Service;
class UserService extends Service implements UserServiceInterface
{
/**
* @return UserModel[]
*/
public function getAll(): array
{
return (new GetAllUsersQuery())->execute($this->context);
}
public function getById(int $id): ?UserModel
{
return (new GetUserByIdQuery($id))->execute($this->context);
}
public function create(string $username, string $email): int
{
(new CreateUserCommand($username, $email))->execute($this->context);
return $this->context->lastInsertId();
}
public function update(int $id, string $email): bool
{
$affected = (new UpdateUserEmailCommand($id, $email))->execute($this->context);
return $affected > 0;
}
public function delete(int $id): bool
{
$affected = (new DeleteUserCommand($id))->execute($this->context);
return $affected > 0;
}
}
Notice the pattern in each method:
- Create a new Query or Command with the required parameters
- Call
execute(), passing$this->context - Return the result in a form the controller can use (model, ID, boolean, etc.)
Tip: There is no need to declare a constructor in your service class. The DI container resolves the base Service constructor parameters automatically. Only add a constructor if you need additional injected dependencies.
Registering Services in the Container
Bind your service interface to its implementation in the DI container. This is typically done in the application bootstrap.
$app->services(function ($container) {
// Interface binding — transient (new instance per resolution)
$container->bind(UserServiceInterface::class, UserService::class);
});
When the container resolves UserServiceInterface, it creates a UserService instance and auto-wires DbContextInterface into the base class constructor. No manual wiring needed.
Registration Options
| Method | Lifecycle | Use When |
|---|---|---|
bind(Interface::class, Implementation::class) |
Transient | A new instance is created every time the service is resolved — the default for most services |
singleton(Interface::class, fn() => new Service(...)) |
Singleton | The same instance is reused for every resolution — use for stateful or expensive services |
// Transient — new instance each time
$container->bind(UserServiceInterface::class, UserService::class);
// Singleton — same instance shared throughout the request
$container->singleton(ReportServiceInterface::class, function () use ($container) {
return new ReportService(
$container->get(DbContextInterface::class),
);
});
Read/Write Separation
The optional $readOnlyContext parameter on the base Service class supports read replica configurations. Register two separate DbContext instances and route queries through the read-only connection.
$app->services(function ($container) use ($app) {
// Write context — primary database
$container->singleton('db.write', function () use ($app) {
return new DbContext(
$app->config('database.primary.dsn'),
$app->config('database.primary.username'),
$app->config('database.primary.password'),
);
});
// Read context — replica database
$container->singleton('db.read', function () use ($app) {
return new DbContext(
$app->config('database.replica.dsn'),
$app->config('database.replica.username'),
$app->config('database.replica.password'),
);
});
// Also bind the write context as the default DbContextInterface
$container->singleton(DbContextInterface::class, function () use ($container) {
return $container->get('db.write');
});
// Service with both contexts
$container->bind(UserServiceInterface::class, function () use ($container) {
return new UserService(
$container->get('db.write'),
$container->get('db.read'),
);
});
});
Then in your service, route reads and writes to the appropriate context:
class UserService extends Service implements UserServiceInterface
{
public function getAll(): array
{
// Reads go to the replica
return (new GetAllUsersQuery())->execute($this->getReadOnlyContext());
}
public function create(string $username, string $email): int
{
// Writes go to the primary
(new CreateUserCommand($username, $email))->execute($this->getContext());
return $this->getContext()->lastInsertId();
}
}
Note: If no read-only context is provided, getReadOnlyContext() falls back to the write context. This means you can add read/write separation later without changing any service code — just update the DI registration.
The Full Chain
Here is the complete request flow from HTTP to database and back, showing how each layer connects.
1. Controller Receives the Request
The controller depends on a service interface, injected by the DI container:
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Services\UserServiceInterface;
use Melodic\Controller\ApiController;
use Melodic\Http\JsonResponse;
use Melodic\Http\Response;
class UserApiController extends ApiController
{
public function __construct(
private readonly UserServiceInterface $userService,
) {}
public function index(): JsonResponse
{
$users = $this->userService->getAll();
return $this->json(array_map(fn($u) => $u->toArray(), $users));
}
public function show(string $id): JsonResponse
{
$user = $this->userService->getById((int) $id);
if ($user === null) {
return $this->notFound(['error' => 'User not found']);
}
return $this->json($user->toArray());
}
public function store(): JsonResponse
{
$body = $this->request->body();
$username = $body['username'] ?? null;
$email = $body['email'] ?? null;
if ($username === null || $email === null) {
return $this->badRequest(['error' => 'Username and email are required']);
}
$id = $this->userService->create($username, $email);
$user = $this->userService->getById($id);
return $this->created($user->toArray(), "/api/users/{$id}");
}
public function destroy(string $id): Response
{
$deleted = $this->userService->delete((int) $id);
if (!$deleted) {
return $this->notFound(['error' => 'User not found']);
}
return $this->noContent();
}
}
2. Service Executes Business Logic
The service creates Query/Command objects and executes them:
// In UserService::getById()
return (new GetUserByIdQuery($id))->execute($this->context);
3. Query/Command Runs Against DbContext
The query calls queryFirst() on the DbContext, which prepares and executes the SQL:
// In GetUserByIdQuery::execute()
return $context->queryFirst(UserModel::class, $this->sql, ['id' => $this->id]);
4. Result Flows Back
DbContext hydrates the result into a UserModel, the query returns it to the service, the service returns it to the controller, and the controller converts it to a JSON response.
DbContext::queryFirst() → UserModel
→ GetUserByIdQuery::execute() → UserModel
→ UserService::getById() → ?UserModel
→ UserApiController::show() → JsonResponse
Best Practices
Keep Services Focused
One service per domain entity or aggregate. A UserService handles user operations; an OrderService handles order operations. If a service grows too large, split it.
Return Domain Models, Not Raw Arrays
Services should return typed model objects. This gives you IDE autocompletion, static analysis support, and a clear contract for what each method returns.
// Good — returns a typed model
public function getById(int $id): ?UserModel
{
return (new GetUserByIdQuery($id))->execute($this->context);
}
// Avoid — returns an untyped array
public function getById(int $id): ?array
{
return $this->context->queryFirst(UserModel::class, $sql, ['id' => $id])?->toArray();
}
Handle Business Validation in Services
Input validation (is the email format valid?) belongs in the controller. Business validation (does this username already exist?) belongs in the service.
public function create(string $username, string $email): int
{
// Business validation
$existing = (new GetUserByUsernameQuery($username))->execute($this->context);
if ($existing !== null) {
throw new \DomainException("Username '{$username}' is already taken.");
}
(new CreateUserCommand($username, $email))->execute($this->context);
return $this->context->lastInsertId();
}
Let the DI Container Manage Lifecycle
Do not instantiate services manually. Register them in the container and let auto-wiring handle dependency resolution. This ensures consistent lifecycle management and makes testing straightforward.
// Good — container manages everything
$container->bind(UserServiceInterface::class, UserService::class);
// Avoid — manual instantiation bypasses the container
$service = new UserService($context);
Use Interfaces to Decouple
Always define a service interface and bind it in the container. Controllers depend on the interface, never the concrete class. This makes it easy to swap implementations for testing or to evolve your architecture.
// Controller depends on the interface
public function __construct(
private readonly UserServiceInterface $userService,
) {}
// Container wires the implementation
$container->bind(UserServiceInterface::class, UserService::class);