Skip to content

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);
    }
}


goldstandard #service-layer #application #architecture