Lab 03 — Multi-module Maven Reactor

Phase: 07 — Production Systems
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 1.5–2 hours
Artifacts: 3-module Maven reactor — queue-api, queue-core, queue-app
Command: mvn verify


Background

A single-module Maven project scales fine to ~5,000 lines of code. Beyond that, you start hitting real problems:

ProblemRoot causeMulti-module fix
Everything recompiles on every changeOne source treeModules only recompile when their inputs change
Dependency versions driftEach dev adds a version independently<dependencyManagement> pins versions once
You can't publish an API jar without implementation detailsAll code in one artifactapi module has zero implementation deps
CI builds the whole world for a 1-line typo fixNo incremental buildmvn -pl queue-api -am builds only what changed

Apache's major projects (Kafka, Spark, Flink, Hadoop) all use this pattern — typically 5–40 modules depending on the project's size.


Module Structure

lab-03-multimodule-maven/        ← parent POM (packaging=pom)
├── pom.xml                      ← declares modules, dependencyManagement, pluginManagement
├── ci.yml                       ← GitHub Actions workflow (copy to .github/workflows/)
├── queue-api/                   ← interfaces + value objects only
│   ├── pom.xml
│   └── src/main/java/org/example/queue/api/
│       ├── Message.java         ← immutable value object
│       └── MessageQueue.java    ← stable API interface
├── queue-core/                  ← implementations (depends on queue-api)
│   ├── pom.xml
│   └── src/main/java/org/example/queue/core/
│       ├── BoundedFifoQueue.java
│       └── PriorityMessageQueue.java
└── queue-app/                   ← composition root + integration tests (depends on queue-core)
    ├── pom.xml
    └── src/
        ├── main/java/org/example/queue/app/Main.java
        └── test/java/org/example/queue/app/QueueIntegrationTest.java

Dependency DAG (Maven resolves build order from this):

queue-api ← queue-core ← queue-app

Step 1 — Understand the Parent POM

Read pom.xml. Find the answers to these questions:

  1. What is the significance of <packaging>pom</packaging>?
  2. What is the difference between <dependencies> and <dependencyManagement>? Add <dependencies> to the parent POM and enqueue a message in a test — what breaks?
  3. What Maven lifecycle phase does mvn verify run through?
Answers
  1. packaging=pom tells Maven: "this artifact is not compiled, there is no jar. Its purpose is to declare child modules and shared configuration."

  2. <dependencyManagement> pins versions but does NOT add the dependency to any module's classpath. Child modules must still declare the dependency; they just omit <version>. If you add <dependencies> to the parent, the dependency appears on every child module's classpath — including modules that don't need it.

  3. mvn verify runs: validate → initialize → compile → test → package → verify. It produces jars AND runs all tests, including integration tests. mvn test stops after the test phase (no jar produced).


Step 2 — Trace the Dependency Graph

Run the effective-pom command to see what Maven resolves for queue-app:

cd queue-app && mvn help:effective-pom | grep -A3 "queue-api\|queue-core\|junit"

Verify that:

  • queue-api appears as a transitive dependency of queue-app (not declared in queue-app/pom.xml)
  • All JUnit and internal dependencies have resolved versions

Step 3 — Build and Test the Full Reactor

cd lab-03-multimodule-maven
mvn verify

Watch the reactor output. It processes modules in dependency order: queue-parent → queue-api → queue-core → queue-app

Expected output:

[INFO] Reactor Build Order:
[INFO] Building queue-parent 1.0-SNAPSHOT  [1/4]
[INFO] Building queue-api 1.0-SNAPSHOT     [2/4]
[INFO] Building queue-core 1.0-SNAPSHOT    [3/4]
[INFO] Building queue-app 1.0-SNAPSHOT     [4/4]
...
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Step 4 — Break the Dependency Direction

Try adding this to queue-api/pom.xml:

<dependency>
    <groupId>org.example.queue</groupId>
    <artifactId>queue-core</artifactId>
</dependency>

Then run mvn verify again. What error do you see?

This is the Maven way of enforcing acyclic dependencies — if api depended on core, the DAG would have a cycle and Maven would fail to build.

Undo this change before proceeding.


Step 5 — Simulate a Version Upgrade

Bump the JUnit version in the parent pom.xml from 5.10.2 to 5.11.0:

<junit.version>5.11.0</junit.version>

Run mvn verify again. Maven downloads 5.11.0 once and uses it for all child modules. All 13 tests still pass because JUnit 5.x is backward-compatible within minor versions.

Revert to 5.10.2 after verifying.

Key insight: In a 20-module project, this one-line change in the parent POM upgrades JUnit for ALL modules consistently. Without <dependencyManagement>, you'd need to update 20 files and risk inconsistency.


Step 6 — Understand the CI Workflow

Read ci.yml. The workflow:

  1. Checks out the repo
  2. Sets up JDK 11 (Temurin = Eclipse Adoptium) with Maven cache
  3. Runs mvn verify --batch-mode from the lab directory
  4. Uploads jars as build artifacts

To use this in a real project, copy it to .github/workflows/ci.yml at your repository root and adjust the working-directory path.


Source Files

Parent pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!--
    Parent POM: packaging=pom means this artifact is never a jar.
    Its job is to:
      1. Declare which child modules exist (<modules>)
      2. Pin all dependency versions in one place (<dependencyManagement>)
      3. Configure plugins once for all children (<build><pluginManagement>)
  -->
    <groupId>org.example.queue</groupId>
    <artifactId>queue-parent</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <!--
    Reactor: Maven builds these modules in dependency order, not declaration order.
    It reads each child pom.xml, builds a DAG, then executes: api → core → app.
  -->
    <modules>
        <module>queue-api</module>
        <module>queue-core</module>
        <module>queue-app</module>
    </modules>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <!-- Single place to update the JUnit version for ALL child modules -->
        <junit.version>5.10.2</junit.version>
    </properties>

    <!--
    dependencyManagement: pins versions but does NOT add them to any module's
    classpath. Child modules must still declare the dependency — they just omit
    the <version> tag and Maven resolves it from here.

    This is the key mechanism that prevents version drift across 10+ modules.
  -->
    <dependencyManagement>
        <dependencies>

            <!-- Internal cross-module dependencies — pinned to this project's version -->
            <dependency>
                <groupId>org.example.queue</groupId>
                <artifactId>queue-api</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>org.example.queue</groupId>
                <artifactId>queue-core</artifactId>
                <version>${project.version}</version>
            </dependency>

            <!--
        JUnit: we use junit-jupiter which is an aggregator artifact that pulls in
        junit-jupiter-api (for @Test) and junit-jupiter-engine (for test execution).
        Declaring it once here means ALL child modules share the same JUnit version.
      -->
            <dependency>
                <groupId>org.junit.jupiter</groupId>
                <artifactId>junit-jupiter</artifactId>
                <version>${junit.version}</version>
                <scope>test</scope>
            </dependency>

        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <!--
        pluginManagement: configures plugins once. Children inherit the version
        and config without repeating it.
      -->
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                    <version>3.2.5</version>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>

</project>

ci.yml

# Note: In a real repository, this file lives at .github/workflows/ci.yml
# relative to the repository root. It is included here for review and learning.
# Copy it to .github/workflows/ci.yml to activate it on GitHub.

name: CI

on:
  push:
    branches: ["main", "master"]
  pull_request:

jobs:
  build:
    name: Build and Test (Java 11)
    runs-on: ubuntu-latest

    steps:
      # 1. Check out the repository
      - name: Checkout
        uses: actions/checkout@v4

      # 2. Set up Java 11 (Temurin = Eclipse Adoptium, the most common free JDK)
      - name: Set up JDK 11
        uses: actions/setup-java@v4
        with:
          java-version: '11'
          distribution: 'temurin'
          # Cache ~/.m2/repository to speed up subsequent builds.
          # Maven's local repo (~250 MB) is keyed on pom.xml hashes.
          cache: maven

      # 3. Build the full reactor: api → core → app, run all tests
      # --batch-mode: suppresses ANSI progress bars (better for CI logs)
      # verify: runs compile → test → package → integration-test phases
      - name: Build and test (full reactor)
        run: mvn verify --batch-mode
        working-directory: PMC-Apache-Comitter-Engineer/phase-07-production-systems/lab-03-multimodule-maven

      # 4. Upload the built jars as artifacts (visible in the GitHub Actions UI)
      - name: Upload jar artifacts
        uses: actions/upload-artifact@v4
        if: success()
        with:
          name: queue-jars
          path: |
            PMC-Apache-Comitter-Engineer/phase-07-production-systems/lab-03-multimodule-maven/**/target/*.jar
          retention-days: 7

queue-api/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!--
    <parent> links this module to the parent POM.
    Maven resolves the parent from the local reactor (if building from the parent)
    or from ~/.m2/repository if built standalone.
  -->
    <parent>
        <groupId>org.example.queue</groupId>
        <artifactId>queue-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <!--
    Only artifactId is needed — groupId and version are inherited from the parent.
    This is one of the key benefits of multi-module: child POMs are minimal.
  -->
    <artifactId>queue-api</artifactId>

    <!--
    queue-api is intentionally thin: it contains only interfaces and value objects.
    It has NO runtime dependencies on other modules.
    Other modules depend on it; it depends on nothing.

    This is the "API module" pattern: code that changes rarely, stable contracts,
    no implementation details. Publishing this as a separate artifact lets
    third-party code depend on the contract without pulling in the implementation.
  -->
    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <!-- No <version> here — resolved from parent's dependencyManagement -->
        </dependency>
    </dependencies>

</project>

Message.java

package org.example.queue.api;

/**
 * An immutable message that can be enqueued and dequeued.
 *
 * <p>
 * Messages carry a {@code priority} value: higher = more urgent.
 * {@link org.example.queue.core.PriorityMessageQueue} uses this to determine
 * dequeue order; {@link org.example.queue.core.BoundedFifoQueue} ignores it.
 */
public final class Message {

    private final String id;
    private final String body;
    private final int priority; // higher value = higher urgency

    public Message(String id, String body, int priority) {
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("id must not be blank");
        }
        if (body == null) {
            throw new IllegalArgumentException("body must not be null");
        }
        this.id = id;
        this.body = body;
        this.priority = priority;
    }

    public String getId() {
        return id;
    }

    public String getBody() {
        return body;
    }

    public int getPriority() {
        return priority;
    }

    @Override
    public String toString() {
        return "Message{id='" + id + "', priority=" + priority + ", body='" + body + "'}";
    }
}

MessageQueue.java

package org.example.queue.api;

import java.util.List;

/**
 * A queue of {@link Message}s.
 *
 * <p>
 * Implementations must be thread-safe (see
 * {@link org.example.queue.core.BoundedFifoQueue} and
 * {@link org.example.queue.core.PriorityMessageQueue}).
 *
 * <p>
 * This is the stable API contract. Callers depend on this interface, not on
 * any implementation class. Switching from FIFO to priority ordering requires
 * only a one-line change at the construction site, not a refactor of all
 * callers.
 */
public interface MessageQueue {

    /**
     * Adds a message to the queue.
     *
     * @param message the message to enqueue; must not be null
     * @throws IllegalArgumentException if message is null
     * @throws IllegalStateException    if the queue is at capacity (bounded queues
     *                                  only)
     */
    void enqueue(Message message);

    /**
     * Removes and returns the next message according to the queue's ordering
     * policy.
     * Returns {@code null} if the queue is empty (no blocking).
     *
     * @return the next message, or {@code null} if empty
     */
    Message dequeue();

    /** Returns the number of messages currently in the queue. */
    int size();

    /** Returns {@code true} if the queue contains no messages. */
    boolean isEmpty();

    /**
     * Removes and returns all messages in the queue in dequeue order.
     * The queue is empty after this call.
     *
     * @return all messages, possibly empty but never null
     */
    List<Message> drainAll();
}

queue-core/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.example.queue</groupId>
        <artifactId>queue-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>queue-core</artifactId>

    <!--
    queue-core depends on queue-api at runtime.
    It implements the MessageQueue interface defined there.

    Notice there is NO <version> on the queue-api dependency — the version
    is resolved from the parent's <dependencyManagement> section.
    When we release version 2.0, we change one line in the parent pom.xml,
    and all child modules automatically use the new version.
  -->
    <dependencies>
        <dependency>
            <groupId>org.example.queue</groupId>
            <artifactId>queue-api</artifactId>
            <!-- version resolved from parent dependencyManagement -->
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
        </dependency>
    </dependencies>

</project>

BoundedFifoQueue.java

package org.example.queue.core;

import org.example.queue.api.Message;
import org.example.queue.api.MessageQueue;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

/**
 * A thread-safe, capacity-bounded FIFO queue.
 *
 * <p>
 * Messages are dequeued in the order they were enqueued (first in, first out).
 * The {@link Message#getPriority()} field is ignored.
 *
 * <p>
 * When the queue reaches its capacity, {@link #enqueue(Message)} throws
 * {@link IllegalStateException} rather than blocking. For backpressure-aware
 * use cases, callers should check {@link #size()} before enqueuing or catch
 * the exception and retry.
 *
 * <p>
 * All public methods are {@code synchronized} on {@code this}.
 * For high-concurrency workloads, prefer
 * {@code java.util.concurrent.LinkedBlockingDeque}.
 */
public class BoundedFifoQueue implements MessageQueue {

    private final Deque<Message> queue;
    private final int capacity;

    /**
     * @param capacity maximum number of messages the queue can hold
     * @throws IllegalArgumentException if capacity <= 0
     */
    public BoundedFifoQueue(int capacity) {
        if (capacity <= 0) {
            throw new IllegalArgumentException("capacity must be > 0");
        }
        this.capacity = capacity;
        this.queue = new ArrayDeque<>(capacity);
    }

    @Override
    public synchronized void enqueue(Message message) {
        if (message == null) {
            throw new IllegalArgumentException("message must not be null");
        }
        if (queue.size() >= capacity) {
            throw new IllegalStateException(
                    "Queue is full (capacity=" + capacity + ")");
        }
        queue.addLast(message);
    }

    @Override
    public synchronized Message dequeue() {
        return queue.pollFirst(); // returns null if empty
    }

    @Override
    public synchronized int size() {
        return queue.size();
    }

    @Override
    public synchronized boolean isEmpty() {
        return queue.isEmpty();
    }

    @Override
    public synchronized List<Message> drainAll() {
        List<Message> result = new ArrayList<>(queue);
        queue.clear();
        return result;
    }
}

PriorityMessageQueue.java

package org.example.queue.core;

import org.example.queue.api.Message;
import org.example.queue.api.MessageQueue;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;

/**
 * A thread-safe, unbounded priority queue.
 *
 * <p>
 * Messages are dequeued in descending priority order: the message with the
 * highest {@link Message#getPriority()} value is dequeued first.
 * Messages with equal priority are dequeued in unspecified (heap) order.
 *
 * <p>
 * This is unbounded — there is no capacity limit. For bounded behavior,
 * use {@link BoundedFifoQueue}.
 *
 * <p>
 * Backed by {@link java.util.PriorityQueue} with a reversed comparator so
 * high-priority messages bubble to the head.
 */
public class PriorityMessageQueue implements MessageQueue {

    // Reversed so that higher priority value = polled first
    private final PriorityQueue<Message> pq = new PriorityQueue<>(
            Comparator.comparingInt(Message::getPriority).reversed());

    @Override
    public synchronized void enqueue(Message message) {
        if (message == null) {
            throw new IllegalArgumentException("message must not be null");
        }
        pq.offer(message);
    }

    @Override
    public synchronized Message dequeue() {
        return pq.poll(); // returns null if empty
    }

    @Override
    public synchronized int size() {
        return pq.size();
    }

    @Override
    public synchronized boolean isEmpty() {
        return pq.isEmpty();
    }

    /**
     * Drains all messages in priority order (highest priority first).
     */
    @Override
    public synchronized List<Message> drainAll() {
        List<Message> result = new ArrayList<>(pq.size());
        while (!pq.isEmpty()) {
            result.add(pq.poll());
        }
        return result;
    }
}

queue-app/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.example.queue</groupId>
        <artifactId>queue-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>queue-app</artifactId>

    <!--
    queue-app depends on queue-core (which transitively brings in queue-api).
    The app module is the "composition root" — it's the only place that knows
    which implementations are in use.
  -->
    <dependencies>
        <dependency>
            <groupId>org.example.queue</groupId>
            <artifactId>queue-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!--
        maven-jar-plugin: produces an executable jar with a Main-Class manifest entry.
        Run with: java -jar target/queue-app-1.0-SNAPSHOT.jar
        Note: this only works if all dependencies are bundled (use maven-shade-plugin
        for a fat jar with dependencies). Here we keep it simple — just the manifest.
      -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>org.example.queue.app.Main</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Main.java

package org.example.queue.app;

import org.example.queue.api.Message;
import org.example.queue.api.MessageQueue;
import org.example.queue.core.BoundedFifoQueue;
import org.example.queue.core.PriorityMessageQueue;

/**
 * Demonstrates both MessageQueue implementations.
 *
 * <p>
 * This is the "composition root" — the only class that imports concrete
 * implementation types ({@link BoundedFifoQueue},
 * {@link PriorityMessageQueue}).
 * All other code works with the {@link MessageQueue} interface.
 *
 * <p>
 * Run from the parent directory:
 * 
 * <pre>
 *   mvn package -pl queue-app -am
 *   java -cp queue-app/target/queue-app-1.0-SNAPSHOT.jar:queue-core/target/queue-core-1.0-SNAPSHOT.jar:queue-api/target/queue-api-1.0-SNAPSHOT.jar org.example.queue.app.Main
 * </pre>
 */
public class Main {

    public static void main(String[] args) {
        System.out.println("=== BoundedFifoQueue Demo ===");
        demonstrateFifo();

        System.out.println("\n=== PriorityMessageQueue Demo ===");
        demonstratePriority();
    }

    private static void demonstrateFifo() {
        MessageQueue queue = new BoundedFifoQueue(5);

        queue.enqueue(new Message("msg-1", "First in", 1));
        queue.enqueue(new Message("msg-2", "Second in", 3));
        queue.enqueue(new Message("msg-3", "Third in", 2));

        System.out.println("Enqueued 3 messages. FIFO order (ignores priority):");
        while (!queue.isEmpty()) {
            System.out.println("  " + queue.dequeue());
        }

        // Show capacity enforcement
        BoundedFifoQueue small = new BoundedFifoQueue(2);
        small.enqueue(new Message("a", "one", 1));
        small.enqueue(new Message("b", "two", 1));
        try {
            small.enqueue(new Message("c", "three", 1));
        } catch (IllegalStateException e) {
            System.out.println("Capacity enforced: " + e.getMessage());
        }
    }

    private static void demonstratePriority() {
        MessageQueue queue = new PriorityMessageQueue();

        queue.enqueue(new Message("alert-1", "Low priority alert", 1));
        queue.enqueue(new Message("alert-2", "High priority alert", 10));
        queue.enqueue(new Message("alert-3", "Medium priority alert", 5));
        queue.enqueue(new Message("alert-4", "Critical alert", 100));

        System.out.println("Enqueued 4 messages. Priority order (highest first):");
        while (!queue.isEmpty()) {
            System.out.println("  " + queue.dequeue());
        }
    }
}

QueueIntegrationTest.java

package org.example.queue.app;

import org.example.queue.api.Message;
import org.example.queue.api.MessageQueue;
import org.example.queue.core.BoundedFifoQueue;
import org.example.queue.core.PriorityMessageQueue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.List;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.*;

/**
 * Integration tests that exercise the full module stack:
 * queue-app → queue-core → queue-api
 *
 * <p>
 * These tests live in queue-app because they use concrete implementation
 * classes
 * ({@link BoundedFifoQueue}, {@link PriorityMessageQueue}) from queue-core and
 * verify
 * that the entire dependency chain is wired correctly.
 *
 * <p>
 * If queue-api changes its {@link org.example.queue.api.MessageQueue} interface
 * in a binary-incompatible way (e.g., adding a default method), this test suite
 * will catch it during the reactor build: queue-core fails to compile →
 * queue-app
 * tests never run → reactor reports BUILD FAILURE.
 */
class QueueIntegrationTest {

    /** Provides both implementations for parameterized contract tests. */
    static Stream<MessageQueue> implementations() {
        return Stream.of(
                new BoundedFifoQueue(100),
                new PriorityMessageQueue());
    }

    // =========================================================================
    // Shared contract tests (parameterized over both implementations)
    // =========================================================================

    @ParameterizedTest
    @MethodSource("implementations")
    void testEnqueueAndDequeue_basicCycle(MessageQueue queue) {
        queue.enqueue(new Message("m1", "hello", 1));
        assertEquals(1, queue.size());

        Message out = queue.dequeue();
        assertNotNull(out);
        assertEquals("m1", out.getId());
        assertTrue(queue.isEmpty());
    }

    @ParameterizedTest
    @MethodSource("implementations")
    void testDequeueOnEmpty_returnsNull(MessageQueue queue) {
        assertNull(queue.dequeue(),
                "dequeue() on an empty queue must return null, not throw");
    }

    @ParameterizedTest
    @MethodSource("implementations")
    void testDrainAll_returnsAllAndClearsQueue(MessageQueue queue) {
        queue.enqueue(new Message("m1", "a", 1));
        queue.enqueue(new Message("m2", "b", 2));
        queue.enqueue(new Message("m3", "c", 3));

        List<Message> drained = queue.drainAll();

        assertEquals(3, drained.size());
        assertTrue(queue.isEmpty(), "Queue must be empty after drainAll()");
    }

    @ParameterizedTest
    @MethodSource("implementations")
    void testEnqueueNull_throws(MessageQueue queue) {
        assertThrows(IllegalArgumentException.class,
                () -> queue.enqueue(null),
                "Enqueuing null must throw IllegalArgumentException");
    }

    // =========================================================================
    // BoundedFifoQueue-specific tests
    // =========================================================================

    /** FIFO: messages must dequeue in insertion order, regardless of priority. */
    @Test
    void testFifo_insertionOrderIsPreserved() {
        BoundedFifoQueue queue = new BoundedFifoQueue(10);
        for (int i = 1; i <= 5; i++) {
            // Enqueue in order 1..5; priority is deliberately descending to show
            // that FIFO ignores priority
            queue.enqueue(new Message("m" + i, "body", 6 - i));
        }
        for (int i = 1; i <= 5; i++) {
            assertEquals("m" + i, queue.dequeue().getId(),
                    "FIFO must dequeue m" + i + " at position " + i);
        }
    }

    @Test
    void testFifo_capacityEnforced() {
        BoundedFifoQueue queue = new BoundedFifoQueue(2);
        queue.enqueue(new Message("m1", "a", 1));
        queue.enqueue(new Message("m2", "b", 1));

        assertThrows(IllegalStateException.class,
                () -> queue.enqueue(new Message("m3", "c", 1)),
                "Enqueueing beyond capacity must throw IllegalStateException");
    }

    @Test
    void testFifo_dequeueAfterPartialDrain() {
        BoundedFifoQueue queue = new BoundedFifoQueue(5);
        queue.enqueue(new Message("m1", "a", 1));
        queue.enqueue(new Message("m2", "b", 1));
        queue.enqueue(new Message("m3", "c", 1));

        assertEquals("m1", queue.dequeue().getId()); // free up one slot
        queue.enqueue(new Message("m4", "d", 1)); // should succeed now

        assertEquals("m2", queue.dequeue().getId());
        assertEquals("m3", queue.dequeue().getId());
        assertEquals("m4", queue.dequeue().getId());
    }

    // =========================================================================
    // PriorityMessageQueue-specific tests
    // =========================================================================

    @Test
    void testPriority_highestPriorityFirst() {
        PriorityMessageQueue queue = new PriorityMessageQueue();
        queue.enqueue(new Message("low", "low priority", 1));
        queue.enqueue(new Message("high", "high priority", 100));
        queue.enqueue(new Message("mid", "mid priority", 50));

        assertEquals("high", queue.dequeue().getId(), "Highest priority must dequeue first");
        assertEquals("mid", queue.dequeue().getId(), "Mid priority must dequeue second");
        assertEquals("low", queue.dequeue().getId(), "Lowest priority must dequeue last");
    }

    @Test
    void testPriority_drainAllIsInPriorityOrder() {
        PriorityMessageQueue queue = new PriorityMessageQueue();
        queue.enqueue(new Message("c", "third", 1));
        queue.enqueue(new Message("a", "first", 10));
        queue.enqueue(new Message("b", "second", 5));

        List<Message> drained = queue.drainAll();

        assertEquals("a", drained.get(0).getId(), "drainAll() position 0 must be highest priority");
        assertEquals("b", drained.get(1).getId(), "drainAll() position 1 must be mid priority");
        assertEquals("c", drained.get(2).getId(), "drainAll() position 2 must be lowest priority");
    }
}