Module 1
Maven for Modern Java Development & TDD
This work is licensed under CC BY-NC-SA 4.0
© Way-Up 2025
A build tool automates the development workflow:
Maven has been the de-facto standard for Java projects since 2004.
my-project/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/ # Application source code
│ │ ├── resources/ # Configuration files
│ │ └── webapp/ # Web resources (for web apps)
│ └── test/
│ ├── java/ # Test source code
│ └── resources/ # Test configuration
└── target/ # Compiled output (generated)
The heart of every Maven project is 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
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-application</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
</project>
Every Maven artifact is uniquely identified by GroupId, ArtifactId, Version:
<groupId>com.example</groupId>
<artifactId>my-application</artifactId>
<version>1.0.0-SNAPSHOT</version>
org.springframework.boot:spring-boot-starter-web:3.2.0com.fasterxml.jackson.core:jackson-databind:2.16.0org.junit.jupiter:junit-jupiter:5.10.1<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.1</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
| Scope | Description | Example |
|---|---|---|
| compile | Default, available everywhere | Spring Framework |
| provided | Provided by runtime environment | Servlet API |
| runtime | Not needed for compilation | JDBC drivers |
| test | Only for testing | JUnit, Mockito |
| import | Import dependencies from BOM | Spring Boot BOM |
Maven automatically resolves dependencies of your dependencies!
Your Project
├── spring-boot-starter-web:3.2.0
├── spring-boot-starter:3.2.0
├── spring-boot-starter-json:3.2.0
├── spring-boot-starter-tomcat:3.2.0
├── spring-web:6.1.0
└── spring-webmvc:6.1.0
├── spring-beans:6.1.0
├── spring-context:6.1.0
└── spring-core:6.1.0
Result: You declare one dependency, Maven downloads 20+ libraries!
Maven has three built-in lifecycles. The Default Lifecycle (most common):
validate - Validate project correctnesscompile - Compile source codetest - Run unit testspackage - Package compiled code (JAR/WAR)verify - Run integration testsinstall - Install package to local repositorydeploy - Deploy to remote repository# Clean the target directory
mvn clean
# Compile the code
mvn compile
# Run tests
mvn test
# Create JAR/WAR
mvn package
# Install to local repo (~/.m2/repository)
mvn install
# Clean and build from scratch
mvn clean install
# Skip tests
mvn clean install -DskipTests
Define reusable values to avoid duplication:
<properties>
<java.version>17</java.version>
<spring.version>6.1.0</spring.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
</dependencies>
Centralize dependency versions using dependencyManagement:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- No version needed - inherited from parent -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.2.0</version>
</plugin>
</plugins>
</build>
Organize large projects into modules:
parent-project/
├── pom.xml (packaging: pom)
├── core-module/
│ └── pom.xml
├── web-module/
│ └── pom.xml
└── data-module/
└── pom.xml
<packaging>pom</packaging>
<modules>
<module>core-module</module>
<module>web-module</module>
<module>data-module</module>
</modules>
The Maven Wrapper ensures consistent Maven version across teams:
# Generate wrapper files
mvn wrapper:wrapper -Dmaven=3.9.6
# Use wrapper instead of installed maven
./mvnw clean install # Unix/Mac/Linux
mvnw.cmd clean install # Windows
Task: Create a simple calculator application with Maven
Task: Create a multi-module project for a web application
myapp-parent/
├── pom.xml (parent)
├── myapp-core/ # Business logic
├── myapp-data/ # Data access layer
└── myapp-web/ # Web layer (depends on core and data)
Task: Analyze and optimize dependencies
mvn dependency:treetarget/ directoriesTesting is crucial for maintaining code quality and catching bugs early.
TDD is a development approach where tests are written before code.
JUnit 5 is the modern standard for Java unit testing.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test
void shouldAddTwoNumbers() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertEquals(5, result);
}
}
import static org.junit.jupiter.api.Assertions.*;
@Test
void testAssertions() {
// Basic assertions
assertEquals(expected, actual);
assertNotEquals(unexpected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
// Array/Collection assertions
assertArrayEquals(expectedArray, actualArray);
assertIterableEquals(expected, actual);
// Exception assertions
assertThrows(IllegalArgumentException.class, () -> {
// Code that should throw exception
});
// Timeout assertions
assertTimeout(Duration.ofSeconds(1), () -> {
// Fast operation
});
}
class UserServiceTest {
private UserService userService;
@BeforeAll
static void initAll() {
// Run once before all tests
System.out.println("Starting test suite");
}
@BeforeEach
void init() {
// Run before each test
userService = new UserService();
}
@Test
void testCreateUser() {
// Test code
}
@AfterEach
void tearDown() {
// Run after each test
userService = null;
}
@AfterAll
static void tearDownAll() {
// Run once after all tests
System.out.println("Test suite completed");
}
}
Run the same test with different inputs:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class StringUtilsTest {
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void shouldDetectBlankStrings(String input) {
assertTrue(StringUtils.isBlank(input));
}
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"5, 5, 10"
})
void shouldAddNumbers(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest
@MethodSource("provideTestData")
void testWithMethodSource(String input, int expected) {
assertEquals(expected, process(input));
}
static Stream provideTestData() {
return Stream.of(
Arguments.of("test", 4),
Arguments.of("hello", 5)
);
}
}
@Test
void shouldDivideTwoNumbers() {
Calculator calc = new Calculator();
double result = calc.divide(10, 2);
assertEquals(5.0, result, 0.001);
}
@Test
void shouldThrowExceptionWhenDividingByZero() {
Calculator calc = new Calculator();
assertThrows(ArithmeticException.class,
() -> calc.divide(10, 0));
}
Tests fail - divide() method doesn't exist yet
public class Calculator {
public double divide(double a, double b) {
if (b == 0) {
throw new ArithmeticException("Division by zero");
}
return a / b;
}
}
Tests pass!
public class Calculator {
public double divide(double dividend, double divisor) {
validateDivisor(divisor);
return dividend / divisor;
}
private void validateDivisor(double divisor) {
if (divisor == 0) {
throw new ArithmeticException("Division by zero");
}
}
}
Maven Surefire plugin runs tests automatically:
# Run all tests
mvn test
# Run specific test class
mvn test -Dtest=CalculatorTest
# Run specific test method
mvn test -Dtest=CalculatorTest#shouldAddTwoNumbers
# Skip tests
mvn install -DskipTests
# Run tests in parallel
mvn test -Dparallel=classes -DthreadCount=4
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
<configuration>
<includes>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
Measure how much of your code is covered by tests:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
mvn test jacoco:report
# Report generated in target/site/jacoco/index.html
Task: Build a StringCalculator using TDD
add(String numbers) that returns sumRemember: Write test first, then make it pass!
GraalVM native-image for faster startup and lower memory footprint
Deterministic builds and verifiable artifacts
Logging is essential for debugging, monitoring, and understanding application behavior.
Log4j 2 is the modern, high-performance logging framework for Java.
<dependencies>
<!-- Log4j 2 API -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.22.1</version>
</dependency>
<!-- Log4j 2 Implementation -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.22.1</version>
</dependency>
</dependencies>
Note: Always use the same version for both API and Core
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class UserService {
private static final Logger logger = LogManager.getLogger(UserService.class);
public void createUser(User user) {
logger.info("Creating user: {}", user.getEmail());
try {
// Business logic
userRepository.save(user);
logger.debug("User saved successfully with ID: {}", user.getId());
} catch (Exception e) {
logger.error("Failed to create user: {}", user.getEmail(), e);
throw e;
}
}
public void deleteUser(Long id) {
logger.warn("Deleting user with ID: {}", id);
userRepository.deleteById(id);
}
}
| Level | Purpose | When to Use |
|---|---|---|
| TRACE | Very detailed diagnostic | Debugging complex flows |
| DEBUG | Debugging information | Development and troubleshooting |
| INFO | Informational messages | Key application events |
| WARN | Potentially harmful situations | Recoverable issues |
| ERROR | Error events | Application errors |
| FATAL | Severe errors | Application-stopping issues |
Create src/main/resources/log4j2.xml:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<!-- Console Appender -->
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
<!-- File Appender -->
<File name="File" fileName="logs/application.log">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n"/>
</File>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Console"/>
<AppenderRef ref="File"/>
</Root>
</Loggers>
</Configuration>
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout>
<Pattern>%d{HH:mm:ss.SSS} %highlight{%-5level} %style{%logger{36}}{cyan} - %msg%n</Pattern>
</PatternLayout>
</Console>
<!-- Rolling File Appender -->
<RollingFile name="RollingFile" fileName="logs/app.log"
filePattern="logs/app-%d{yyyy-MM-dd}-%i.log">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%t] %logger{36} - %msg%n"/>
<Policies>
<TimeBasedTriggeringPolicy/>
<SizeBasedTriggeringPolicy size="10 MB"/>
</Policies>
<DefaultRolloverStrategy max="10"/>
</RollingFile>
</Appenders>
<Loggers>
<Logger name="com.example" level="debug" additivity="false">
<AppenderRef ref="Console"/>
<AppenderRef ref="RollingFile"/>
</Logger>
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
Understanding the pattern format:
%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%t] %logger{36} - %msg%n
| Pattern | Description | Example |
|---|---|---|
%d{...} |
Date/time | 2025-01-15 14:30:45.123 |
%-5level |
Log level (left-aligned, 5 chars) | INFO, ERROR |
%t |
Thread name | main, pool-1-thread-1 |
%logger{36} |
Logger name (max 36 chars) | com.example.UserService |
%msg |
Log message | User created successfully |
%n |
Newline | \n |
// Good - lazy evaluation
logger.info("User {} logged in from {}", username, ipAddress);
// Bad - string concatenation always executes
logger.info("User " + username + " logged in from " + ipAddress);
// Bad
logger.info("Password: {}", user.getPassword());
// Good
logger.info("User {} authenticated successfully", user.getUsername());
In this module, you learned:
Next Module: Dependency Injection & Spring Framework