Gelaagde Architectuur in de Praktijk: Een Complete Case Studie

πŸ–‹οΈ bert

# Inleiding

Stel je voor: je bent net begonnen als ontwikkelaar bij een groeiende webshop en krijgt de taak om het bestaande "spaghetti-code" systeem om te bouwen naar een schaalbare, onderhoudbare applicatie. Klinkt bekend? In deze blog nemen we je mee door een praktische implementatie van een gelaagde architectuur aan de hand van een realistische case studie.

# De Case: BookHub - Online Boekwinkel

We gaan een online boekwinkel bouwen genaamd "BookHub". Deze webshop moet boeken verkopen, voorraad bijhouden, bestellingen verwerken en klantgegevens beheren. Perfect om alle lagen van onze architectuur te demonstreren!

# Architectuur Overview

Onze applicatie bestaat uit zeven hoofdlagen, elk met een specifieke verantwoordelijkheid:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           Presentation Layer            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚             Service Layer               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚             Factory Layer               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚           Repository Layer              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚           Data Source Layer             β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚        Cross Cutting Concerns           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚         Infrastructure Layer            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

# Laag voor Laag Implementatie

# 1. Presentation Layer - De Voorkant

De presentation layer is wat de gebruiker ziet en waarmee zij interacteren. Voor BookHub implementeren we hier onze controllers en views.

BookController.php:

class BookController 
{
    private BookService $bookService;

    public function __construct(BookService $bookService) 
    {
        $this->bookService = $bookService;
    }

    public function index(Request $request): Response 
    {
        $books = $this->bookService->getAllBooks(
            $request->get('category'),
            $request->get('page', 1)
        );

        return view('books.index', ['books' => $books]);
    }

    public function show(int $id): Response 
    {
        $book = $this->bookService->getBookById($id);

        if (!$book) {
            return response()->json(['error' => 'Book not found'], 404);
        }

        return view('books.show', ['book' => $book]);
    }
}

Routing configuratie:

Route::get('/books', [BookController::class, 'index']);
Route::get('/books/{id}', [BookController::class, 'show']);
Route::post('/books', [BookController::class, 'store'])->middleware('auth');

Waarom is dit slim? De controller houdt zich alleen bezig met HTTP-verzoeken en -antwoorden. Alle businesslogica delegeert hij naar de service layer.

# 2. Service Layer - Het Brein van de Operatie

De service layer bevat alle businesslogica. Hier gebeurt de magie van BookHub!

BookService.php:

class BookService 
{
    private BookRepository $bookRepository;
    private InventoryService $inventoryService;
    private PricingService $pricingService;

    public function __construct(
        BookRepository $bookRepository,
        InventoryService $inventoryService,
        PricingService $pricingService
    ) {
        $this->bookRepository = $bookRepository;
        $this->inventoryService = $inventoryService;
        $this->pricingService = $pricingService;
    }

    public function getAllBooks(?string $category = null, int $page = 1): array 
    {
        $books = $this->bookRepository->findByCategory($category, $page);

        // Businesslogica: voeg live voorraad en dynamische prijzen toe
        foreach ($books as $book) {
            $book->available_stock = $this->inventoryService->getAvailableStock($book->id);
            $book->current_price = $this->pricingService->calculatePrice($book);
        }

        return $books;
    }

    public function purchaseBook(int $bookId, int $quantity): OrderResult 
    {
        DB::beginTransaction();
        try {
            // Complexe businesslogica voor een aankoop
            $book = $this->bookRepository->findById($bookId);

            if (!$this->inventoryService->isAvailable($bookId, $quantity)) {
                throw new InsufficientStockException();
            }

            $price = $this->pricingService->calculatePrice($book, $quantity);
            $this->inventoryService->reserveStock($bookId, $quantity);

            $order = $this->createOrder($book, $quantity, $price);

            DB::commit();
            return new OrderResult($order, true);

        } catch (Exception $e) {
            DB::rollback();
            return new OrderResult(null, false, $e->getMessage());
        }
    }
}

PricingService.php:

class PricingService 
{
    public function calculatePrice(Book $book, int $quantity = 1): float 
    {
        $basePrice = $book->base_price;

        // Businessregel: korting bij bulk aankoop
        if ($quantity >= 10) {
            $basePrice *= 0.9; // 10% korting
        }

        // Businessregel: premium voor nieuwe boeken
        if ($book->publication_date->diffInDays(now()) < 30) {
            $basePrice *= 1.1; // 10% toeslag
        }

        return round($basePrice * $quantity, 2);
    }
}

# 3. Factory Layer - De Objectbouwer

Factories helpen ons complexe objecten te creΓ«ren zonder dat andere lagen zich druk hoeven te maken over de details.

BookFactory.php:

abstract class BookFactory 
{
    abstract public function createBook(array $data): Book;
    abstract public function createBookCollection(array $booksData): BookCollection;
}

class PhysicalBookFactory extends BookFactory 
{
    public function createBook(array $data): Book 
    {
        $book = new PhysicalBook();
        $book->title = $data['title'];
        $book->author = $data['author'];
        $book->isbn = $data['isbn'];
        $book->weight = $data['weight'] ?? 0;
        $book->dimensions = $data['dimensions'] ?? null;

        return $book;
    }

    public function createBookCollection(array $booksData): BookCollection 
    {
        $books = array_map([$this, 'createBook'], $booksData);
        return new BookCollection($books);
    }
}

class EBookFactory extends BookFactory 
{
    public function createBook(array $data): Book 
    {
        $book = new EBook();
        $book->title = $data['title'];
        $book->author = $data['author'];
        $book->isbn = $data['isbn'];
        $book->file_size = $data['file_size'];
        $book->format = $data['format'] ?? 'PDF';

        return $book;
    }

    public function createBookCollection(array $booksData): BookCollection 
    {
        $books = array_map([$this, 'createBook'], $booksData);
        return new BookCollection($books);
    }
}

FactoryProvider.php:

class BookFactoryProvider 
{
    public static function getFactory(string $bookType): BookFactory 
    {
        return match($bookType) {
            'physical' => new PhysicalBookFactory(),
            'ebook' => new EBookFactory(),
            'audiobook' => new AudioBookFactory(),
            default => throw new InvalidArgumentException("Unknown book type: $bookType")
        };
    }
}

# 4. Repository Layer - De Datamakelaar

De repository layer zorgt voor alle database-interacties en houdt de rest van de applicatie database-agnostisch.

BookRepository.php:

interface BookRepositoryInterface 
{
    public function findById(int $id): ?Book;
    public function findByCategory(?string $category, int $page): array;
    public function save(Book $book): bool;
    public function delete(int $id): bool;
}

class EloquentBookRepository implements BookRepositoryInterface 
{
    public function findById(int $id): ?Book 
    {
        $bookData = DB::table('books')
            ->where('id', $id)
            ->where('active', true)
            ->first();

        if (!$bookData) {
            return null;
        }

        // Gebruik factory om het juiste type book object te maken
        $factory = BookFactoryProvider::getFactory($bookData->type);
        return $factory->createBook((array) $bookData);
    }

    public function findByCategory(?string $category = null, int $page = 1): array 
    {
        $query = DB::table('books')
            ->where('active', true)
            ->orderBy('title');

        if ($category) {
            $query->where('category', $category);
        }

        $booksData = $query
            ->offset(($page - 1) * 20)
            ->limit(20)
            ->get()
            ->toArray();

        // Groepeer per type en gebruik de juiste factories
        $booksByType = collect($booksData)->groupBy('type');
        $allBooks = [];

        foreach ($booksByType as $type => $books) {
            $factory = BookFactoryProvider::getFactory($type);
            $bookObjects = $factory->createBookCollection($books->toArray());
            $allBooks = array_merge($allBooks, $bookObjects->getBooks());
        }

        return $allBooks;
    }

    public function save(Book $book): bool 
    {
        $data = [
            'title' => $book->title,
            'author' => $book->author,
            'isbn' => $book->isbn,
            'type' => $book->getType(),
            'category' => $book->category,
            'base_price' => $book->base_price,
            'updated_at' => now()
        ];

        if ($book->id) {
            return DB::table('books')
                ->where('id', $book->id)
                ->update($data);
        } else {
            $data['created_at'] = now();
            $book->id = DB::table('books')->insertGetId($data);
            return true;
        }
    }
}

Query Specifications (bonus patroon):

class BookSpecification 
{
    public function __construct(
        private ?string $category = null,
        private ?string $author = null,
        private ?float $minPrice = null,
        private ?float $maxPrice = null
    ) {}

    public function toQuery(): Builder 
    {
        $query = DB::table('books')->where('active', true);

        if ($this->category) {
            $query->where('category', $this->category);
        }

        if ($this->author) {
            $query->where('author', 'LIKE', "%{$this->author}%");
        }

        if ($this->minPrice) {
            $query->where('base_price', '>=', $this->minPrice);
        }

        if ($this->maxPrice) {
            $query->where('base_price', '<=', $this->maxPrice);
        }

        return $query;
    }
}

# 5. Data Source Layer - De Funderingen

Deze laag bevat onze database configuraties, cache instellingen en externe API verbindingen.

database/migrations/2024_01_01_create_books_table.php:

Schema::create('books', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('author');
    $table->string('isbn')->unique();
    $table->enum('type', ['physical', 'ebook', 'audiobook']);
    $table->string('category');
    $table->decimal('base_price', 8, 2);
    $table->json('metadata')->nullable(); // Voor type-specifieke data
    $table->boolean('active')->default(true);
    $table->timestamps();

    $table->index(['category', 'active']);
    $table->index(['author', 'active']);
});

config/database.php:

'connections' => [
    'mysql' => [
        'driver' => 'mysql',
        'host' => env('DB_HOST', '127.0.0.1'),
        'database' => env('DB_DATABASE', 'bookhub'),
        'username' => env('DB_USERNAME', 'forge'),
        'password' => env('DB_PASSWORD', ''),
        'charset' => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
    ],

    'redis' => [
        'driver' => 'redis',
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'port' => env('REDIS_PORT', 6379),
    ]
]

# 6. Cross Cutting Concerns - De Ondersteunende Cast

Deze aspecten lopen door alle lagen heen en zorgen voor logging, security, caching en configuratie.

LoggingMiddleware.php:

class LoggingMiddleware 
{
    public function handle(Request $request, Closure $next): Response 
    {
        $startTime = microtime(true);

        Log::info('Request started', [
            'method' => $request->method(),
            'url' => $request->fullUrl(),
            'user_id' => auth()->id(),
            'ip' => $request->ip()
        ]);

        $response = $next($request);

        $duration = microtime(true) - $startTime;

        Log::info('Request completed', [
            'status' => $response->status(),
            'duration' => round($duration * 1000, 2) . 'ms'
        ]);

        return $response;
    }
}

CacheService.php:

class CacheService 
{
    private const BOOK_CACHE_TTL = 3600; // 1 uur

    public function getCachedBooks(string $category = 'all', int $page = 1): ?array 
    {
        $key = "books:{$category}:page:{$page}";
        return Cache::get($key);
    }

    public function cacheBooks(array $books, string $category = 'all', int $page = 1): void 
    {
        $key = "books:{$category}:page:{$page}";
        Cache::put($key, $books, self::BOOK_CACHE_TTL);
    }

    public function invalidateBookCache(int $bookId): void 
    {
        // Invalideer alle gerelateerde cache keys
        $patterns = [
            "books:*",
            "book:{$bookId}:*"
        ];

        foreach ($patterns as $pattern) {
            Cache::tags(['books'])->flush();
        }
    }
}

SecurityService.php:

class SecurityService 
{
    public function canUserAccessBook(User $user, Book $book): bool 
    {
        // Businessregel: gebruikers kunnen alleen actieve boeken zien
        if (!$book->active) {
            return $user->hasRole('admin');
        }

        // Businessregel: premium boeken alleen voor premium gebruikers
        if ($book->category === 'premium') {
            return $user->hasSubscription('premium');
        }

        return true;
    }

    public function canUserPurchaseBook(User $user, Book $book): bool 
    {
        if (!$this->canUserAccessBook($user, $book)) {
            return false;
        }

        // Businessregel: check of gebruiker niet geblokkeerd is
        return !$user->is_blocked;
    }
}

# 7. Infrastructure Layer - De Technische Basis

De infrastructure layer bevat al onze technische configuraties en deployment scripts.

docker-compose.yml:

version: '3.8'
services:
  app:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - .:/var/www/html
    depends_on:
      - mysql
      - redis

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: bookhub
      MYSQL_ROOT_PASSWORD: secret
    ports:
      - "3306:3306"

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf

.github/workflows/deploy.yml:

name: Deploy BookHub
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.2

      - name: Install dependencies
        run: composer install --no-dev

      - name: Run tests
        run: php artisan test

      - name: Deploy to production
        run: |
          php artisan migrate --force
          php artisan config:cache
          php artisan route:cache

# De Lagen in Actie: Een Complete User Story

Laten we zien hoe alle lagen samenwerken wanneer een klant een boek koopt:

# Scenario: Marie koopt "De Avonden" van Gerard Reve

  1. Presentation Layer: Marie klikt op "Koop Nu" β†’ BookController::purchase() wordt aangeroepen
  2. Service Layer: BookService::purchaseBook() start de businesslogica
  3. Factory Layer: Het systeem maakt een PhysicalBook object aan via PhysicalBookFactory
  4. Repository Layer: BookRepository::findById() haalt boekgegevens op uit database
  5. Data Source Layer: MySQL database retourneert de boekgegevens
  6. Cross Cutting Concerns:
    • Logging registreert de aankoop poging
    • Security controleert of Marie het boek mag kopen
    • Cache wordt geΓ―nvalideerd na succesvolle aankoop
  7. Infrastructure Layer: Nginx zorgt voor de HTTP afhandeling, Docker houdt alles draaiende

# Voordelen van Deze Architectuur

# πŸ”§ Onderhoudbaarheid

Elke laag heeft een duidelijke verantwoordelijkheid. Wil je de database vervangen? Pas alleen de Repository layer aan. Nieuwe businessregels? Werk alleen de Service layer bij.

# πŸ§ͺ Testbaarheid

class BookServiceTest extends TestCase 
{
    public function test_book_purchase_with_insufficient_stock(): void 
    {
        // Arrange: Mock de dependencies
        $mockRepository = Mockery::mock(BookRepository::class);
        $mockInventory = Mockery::mock(InventoryService::class);

        $mockInventory->shouldReceive('isAvailable')
            ->with(1, 2)
            ->andReturn(false);

        $service = new BookService($mockRepository, $mockInventory, $mockPricing);

        // Act & Assert
        $this->expectException(InsufficientStockException::class);
        $service->purchaseBook(1, 2);
    }
}

# πŸ“ˆ Schaalbaarheid

Verschillende onderdelen kunnen onafhankelijk geschaald worden. Veel database queries? Scale de Repository layer. Complexe businesslogica? Scale de Service layer.

# πŸ”„ Flexibiliteit

Wil je van MySQL naar PostgreSQL? Implementeer een nieuwe PostgresBookRepository die dezelfde interface gebruikt. De rest van je applicatie merkt er niets van!

# Veelgemaakte Fouten (en Hoe Ze Te Vermijden)

# ❌ Fout: Direct database calls in Controllers

// NIET DOEN!
class BookController 
{
    public function show(int $id) 
    {
        $book = DB::table('books')->where('id', $id)->first();
        return view('book', compact('book'));
    }
}

# βœ… Goed: Via de Service layer

// WEL DOEN!
class BookController 
{
    public function show(int $id) 
    {
        $book = $this->bookService->getBookById($id);
        return view('book', compact('book'));
    }
}

# ❌ Fout: Businesslogica in de Repository

// NIET DOEN!
class BookRepository 
{
    public function findAvailableBooks() 
    {
        return DB::table('books')
            ->where('stock', '>', 0)
            ->where('price', '<', 50) // Businessregel hoort hier niet!
            ->get();
    }
}

# βœ… Goed: Businesslogica in de Service

// WEL DOEN!
class BookService 
{
    public function getAffordableBooks() 
    {
        $allBooks = $this->bookRepository->findInStock();

        // Businessregel: betaalbare boeken zijn onder €50
        return array_filter($allBooks, fn($book) => $book->price < 50);
    }
}

# Conclusie

Een gelaagde architectuur lijkt in het begin misschien overkill voor een simpele webshop, maar de voordelen worden snel duidelijk naarmate je project groeit. Door elke laag een duidelijke verantwoordelijkheid te geven, creΓ«er je een systeem dat:

  • Makkelijk te begrijpen is voor nieuwe ontwikkelaars
  • Eenvoudig uit te breiden is met nieuwe functionaliteit
  • Goed testbaar is door de scheiding van concerns
  • Flexibel is bij wijzigingen in requirements of technologie

De investering in een goede architectuur betaalt zich dubbel en dwars terug wanneer je systeem complexer wordt. En het mooiste? Je toekomstige zelf (en je collega's) zullen je er dankbaar voor zijn!

# Volgende Stappen

Wil je zelf aan de slag met gelaagde architectuur? Begin klein:

  1. Start met een simpel CRUD systeem
  2. Identificeer de verschillende verantwoordelijkheden
  3. Maak aparte klassen voor elke laag
  4. Schrijf tests voor elke laag afzonderlijk
  5. Bouw geleidelijk uit met meer complexe businesslogica

Reacties (0 )

Geen reacties beschikbaar.