# 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
- Presentation Layer: Marie klikt op "Koop Nu" β
BookController::purchase()
wordt aangeroepen - Service Layer:
BookService::purchaseBook()
start de businesslogica - Factory Layer: Het systeem maakt een
PhysicalBook
object aan viaPhysicalBookFactory
- Repository Layer:
BookRepository::findById()
haalt boekgegevens op uit database - Data Source Layer: MySQL database retourneert de boekgegevens
- Cross Cutting Concerns:
- Logging registreert de aankoop poging
- Security controleert of Marie het boek mag kopen
- Cache wordt geΓ―nvalideerd na succesvolle aankoop
- 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:
- Start met een simpel CRUD systeem
- Identificeer de verschillende verantwoordelijkheden
- Maak aparte klassen voor elke laag
- Schrijf tests voor elke laag afzonderlijk
- Bouw geleidelijk uit met meer complexe businesslogica
Reacties (0 )
Geen reacties beschikbaar.
Log in om een reactie te plaatsen.