Service Layer¶
Application services that orchestrate business logic in the Gold Standard Module.
Overview¶
The Service Layer sits between the Presentation and Domain layers, coordinating use cases without containing business logic itself.
flowchart TB
subgraph Presentation
C[Controller]
end
subgraph Application["Service Layer"]
AS[ArticleService]
CS[CommentService]
NS[NotificationService]
end
subgraph Domain
E[Entities]
R[Repositories]
DS[Domain Services]
end
C --> AS
C --> CS
AS --> E
AS --> R
AS --> NS
CS --> R
NS --> DS Service Responsibilities¶
| Responsibility | Example |
|---|---|
| Transaction management | Begin/commit/rollback |
| Use case orchestration | Create article workflow |
| Cross-cutting concerns | Logging, caching |
| DTO transformation | Entity → Response DTO |
| Event dispatching | Publish domain events |
What services should NOT do: - Contain business rules (belongs in Domain) - Direct database queries (use Repositories) - HTTP concerns (belongs in Controllers)
ArticleService¶
The main service for article operations:
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Application\Service;
use Xoops\GoldStandard\Application\Command\CreateArticleCommand;
use Xoops\GoldStandard\Application\Command\PublishArticleCommand;
use Xoops\GoldStandard\Application\DTO\ArticleDTO;
use Xoops\GoldStandard\Domain\Entity\Article;
use Xoops\GoldStandard\Domain\Repository\ArticleRepository;
use Xoops\GoldStandard\Domain\ValueObject\ArticleId;
final readonly class ArticleService
{
public function __construct(
private ArticleRepository $articleRepository,
private EventDispatcherInterface $eventDispatcher,
private CacheInterface $cache,
) {}
public function createArticle(CreateArticleCommand $command): ArticleDTO
{
// Generate identity
$id = $this->articleRepository->nextIdentity();
// Create domain entity
$article = Article::create(
id: $id,
title: ArticleTitle::fromString($command->title),
content: ArticleContent::fromString($command->content),
authorId: AuthorId::fromString($command->authorId),
);
// Persist
$this->articleRepository->save($article);
// Dispatch domain events
foreach ($article->pullDomainEvents() as $event) {
$this->eventDispatcher->dispatch($event);
}
// Return DTO
return ArticleDTO::fromEntity($article);
}
public function publishArticle(PublishArticleCommand $command): ArticleDTO
{
$article = $this->articleRepository->findOrFail(
ArticleId::fromString($command->articleId)
);
// Domain operation
$article->publish();
// Persist
$this->articleRepository->save($article);
// Invalidate cache
$this->cache->delete("article:{$command->articleId}");
// Dispatch events
foreach ($article->pullDomainEvents() as $event) {
$this->eventDispatcher->dispatch($event);
}
return ArticleDTO::fromEntity($article);
}
public function getArticle(string $id): ?ArticleDTO
{
return $this->cache->remember(
"article:{$id}",
3600,
function () use ($id) {
$article = $this->articleRepository->find(
ArticleId::fromString($id)
);
return $article ? ArticleDTO::fromEntity($article) : null;
}
);
}
}
Command Objects¶
Commands represent intentions to change state:
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Application\Command;
final readonly class CreateArticleCommand
{
public function __construct(
public string $title,
public string $content,
public string $authorId,
public ?string $categoryId = null,
public array $tags = [],
) {}
}
Command Validation¶
<?php
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateArticleCommand
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 5, max: 200)]
public string $title,
#[Assert\NotBlank]
#[Assert\Length(min: 100)]
public string $content,
#[Assert\NotBlank]
#[Assert\Ulid]
public string $authorId,
) {}
}
Query Services¶
Separate read operations from writes:
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Application\Query;
final readonly class ArticleQueryService
{
public function __construct(
private ArticleReadRepository $readRepository,
private CacheInterface $cache,
) {}
public function findPublished(
int $page = 1,
int $perPage = 20,
): PaginatedResult {
$cacheKey = "articles:published:{$page}:{$perPage}";
return $this->cache->remember(
$cacheKey,
300,
fn() => $this->readRepository->findPublished($page, $perPage)
);
}
public function findByCategory(
string $categoryId,
int $page = 1,
): PaginatedResult {
return $this->readRepository->findByCategory(
CategoryId::fromString($categoryId),
$page
);
}
public function search(string $query): array
{
return $this->readRepository->search($query);
}
}
Transaction Management¶
<?php
final readonly class ArticleService
{
public function __construct(
private TransactionManager $transactionManager,
// ... other dependencies
) {}
public function createArticleWithMedia(
CreateArticleCommand $command,
array $mediaFiles,
): ArticleDTO {
return $this->transactionManager->transactional(
function () use ($command, $mediaFiles) {
// Create article
$article = $this->createArticle($command);
// Attach media (in same transaction)
foreach ($mediaFiles as $file) {
$this->mediaService->attachToArticle(
$article->id,
$file
);
}
return $article;
}
);
}
}
Data Transfer Objects (DTOs)¶
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Application\DTO;
final readonly class ArticleDTO
{
public function __construct(
public string $id,
public string $title,
public string $content,
public string $status,
public string $authorId,
public string $authorName,
public string $createdAt,
public ?string $publishedAt,
) {}
public static function fromEntity(Article $article): self
{
return new self(
id: $article->id->toString(),
title: $article->title()->toString(),
content: $article->content()->toString(),
status: $article->status()->value,
authorId: $article->authorId()->toString(),
authorName: $article->author()->name(),
createdAt: $article->createdAt()->format('c'),
publishedAt: $article->publishedAt()?->format('c'),
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'content' => $this->content,
'status' => $this->status,
'author' => [
'id' => $this->authorId,
'name' => $this->authorName,
],
'created_at' => $this->createdAt,
'published_at' => $this->publishedAt,
];
}
}
Service Registration¶
Register services in the DI container:
// config/services.php
use function DI\autowire;
use function DI\get;
return [
ArticleService::class => autowire()
->constructorParameter('cache', get(CacheInterface::class)),
ArticleQueryService::class => autowire(),
CommentService::class => autowire(),
NotificationService::class => autowire(),
];
Using Services in Controllers¶
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Presentation\Controller;
final readonly class ArticleController
{
public function __construct(
private ArticleService $articleService,
private ArticleQueryService $queryService,
) {}
public function create(Request $request): Response
{
$command = new CreateArticleCommand(
title: $request->get('title'),
content: $request->get('content'),
authorId: $this->getCurrentUserId(),
);
$article = $this->articleService->createArticle($command);
return new JsonResponse($article->toArray(), 201);
}
public function index(Request $request): Response
{
$page = (int) $request->get('page', 1);
$articles = $this->queryService->findPublished($page);
return new JsonResponse($articles);
}
}
Testing Services¶
<?php
declare(strict_types=1);
namespace Xoops\GoldStandard\Tests\Application;
use PHPUnit\Framework\TestCase;
final class ArticleServiceTest extends TestCase
{
private ArticleService $service;
private ArticleRepository&MockObject $repository;
private EventDispatcherInterface&MockObject $dispatcher;
protected function setUp(): void
{
$this->repository = $this->createMock(ArticleRepository::class);
$this->dispatcher = $this->createMock(EventDispatcherInterface::class);
$this->service = new ArticleService(
$this->repository,
$this->dispatcher,
new NullCache(),
);
}
#[Test]
public function it_creates_article(): void
{
$command = new CreateArticleCommand(
title: 'Test Article',
content: 'Test content...',
authorId: '01HX1234567890ABCDEFGHIJK',
);
$this->repository
->expects($this->once())
->method('nextIdentity')
->willReturn(ArticleId::generate());
$this->repository
->expects($this->once())
->method('save');
$this->dispatcher
->expects($this->once())
->method('dispatch')
->with($this->isInstanceOf(ArticleCreated::class));
$result = $this->service->createArticle($command);
$this->assertInstanceOf(ArticleDTO::class, $result);
$this->assertSame('Test Article', $result->title);
}
}