Build Blog Platform
Build Blog Platform
This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
License.
Attribution — You must give appropriate credit to Devtiro, provide a link to the license, and indicate if changes
were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses
you or your use.
NonCommercial — You may not use the material for commercial purposes.
ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under
the same license as the original.
No additional restrictions — You may not apply legal terms or technological measures that legally restrict others
from doing anything the license permits.
Notices:
You do not have to comply with the license for elements of the material in the public domain or where your use
is permitted by an applicable exception or limitation.
No warranties are given. The license may not give you all of the permissions necessary for your intended use.
For example, other rights such as publicity, privacy, or moral rights may limit how you use the material.
"Build a Blog Platform App by Devtiro is licensed under CC BY-NC-SA 4.0. Original content available at https://
www.youtube.com/@devtiro and https://www.devtiro.com."
To follow along and build the blog platform app you’ll need a practical knowledge of Java, Maven,
Docker and PostgreSQL.
By “practical knowledge” I mean that I won’t be explaining how technologies work in this project,
instead I’ll assume you’re already comfortable with them so we can focus on building our project.
For Spring Boot, you should be comfortable with the basic concepts.
Where this project becomes more challenging is that we’ll be using Spring Security and JSON Web
Tokens, or JWTs. So a basic knowledge of Spring Security and how JWTs work would be very
beneficial, although not strictly needed, as once our security is configured we’ll not need to revisit it.
A special note about our project’s React frontend. Of course, this project is focused on building a
Spring Boot application, however I do offer the same React frontend I use.
We’ll be using the Node Package Manager (npm) to install the dependencies for the frontend, so
although you don’t need to know how Node works, or how to build a React application, it would be
beneficial to know what Node is and what is means to use npm. Otherwise, a quick read of the Node
website should be enough to understand this.
Summary
Java
You can check your Java version by opening up a terminal or a command prompt and typing:
java -version
If you see an earlier version then I’d recommend heading over to oracle.com to download JDK 21 or
later.
Or if you prefer to use an open source JDK, then you can download it from Adoptium, which is a part
of the Eclipse Foundation.
Maven
We’ll be using Apache Maven to manage our project, although you’ll not need to install this on your
system as an instance of Maven will come bundled with your skeleton Spring Boot project, but more
on that later.
Node
To run the frontend code I’ll provide, you’ll need Node version 20 or later.
You can check your Node version by opening up a terminal or a command prompt and typing:
node --version
Note it’s two dashes before “version” for node, but only one for Java.
If you don’t have the required version then head over to nodejs.org to download a later version of
node.
Docker
To run the PostgreSQL database we’ll be using later you’ll need docker installed on your machine.
To check you have docker installed, open up a terminal or command prompt and type:
docker --version
You get a version number printed out, otherwise head over to docker.com to download docker.
IDE
I’m going to be using the community version of IntelliJ IDEA as the IDE for this project.
You can download IntelliJ for free from the JetBrains website.
Summary
With everything installed, let’s check out the requirements of what we are going to be building.
Spring Initializr provides a web-based tool for generating Spring Boot projects with the necessary
dependencies and configuration.
The interface presents various options for customizing your project structure and dependencies.
Project: Maven
Language: Java
Spring Boot: 3.4.0
Group: com.devtiro
Artifact: blog
Name: blog
Description: A blog platform
Package name: com.devtiro.blog
Packaging: Jar
Java: 21
Dependency Selection
Dependencies determine which Spring Boot features and libraries will be available in your project.
Spring Web
Spring Data JPA
PostgreSQL Driver
Spring Security
Lombok
Validation
H2
After generating the project, you need to set it up in your development environment.
Summary
• The Spring Initializr (start.spring.io) provides a web interface for generating Spring Boot projects
• Project configuration includes basic metadata like group ID, artifact ID, and Java version
• Essential dependencies for a blog platform include Web, JPA, Security, and PostgreSQL support
• The generated project contains all necessary configuration files and a basic project structure
Project Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
Project Structure
Spring Boot follows a conventional project structure that promotes organization and maintainability.
src/main/java/com/devtiro/blog/
├── BlogApplication.java
├── controllers/ <= We'll create this later
├── services/ <= We'll create this later
├── repositories/ <= We'll create this later
├── domain/ <= We'll create this later
└── config/
@SpringBootApplication
public class BlogApplication {
To make sure our project is set up correctly, we can use Maven to build and run tests.
# Windows
.\mvwn clean install
# *nix
./mvnw clean install
Summary
• The pom.xml file manages project dependencies including Spring Web, JPA, and PostgreSQL
• The @SpringBootApplication annotation enables auto-configuration and component scanning
and mark our application as a Spring Boot application
This containerized approach ensures consistency across development environments and simplifies
database management.
Docker Compose is a tool that defines and manages multi-container Docker environments.
For our blog platform, we need a PostgreSQL database server and (optionally) a database
management interface.
Docker Compose allows us to define these services in a declarative way using a YAML file.
Let's create a new file named docker-compose.yml in the project root directory with the following
content:
services:
# Our PostgreSQL database
db:
# Using the latest PostgreSQL image
image: postgres:latest
ports:
- "5432:5432"
restart: always
environment:
POSTGRES_PASSWORD: changemeinprod!
Starting the database requires Docker to be installed and running on your system.
docker-compose up
You can access the Adminer interface at http://localhost:8888 with these credentials:
• System: PostgreSQL
• Server: db
• Username: postgres
• Password: changemeinprod!
Summary
Database Configuration
Spring Boot uses the application.properties file to manage database connection settings.
# Database Connection
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=postgres
spring.datasource.password=changemeinprod!
# JPA Configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
The database URL follows a specific format that tells Spring how to connect to our PostgreSQL
instance.
The show-sql and format_sql properties help during development by logging SQL statements to the
console.
Summary
We'll only be setting up MapStruct in this lesson, we'll cover using MapStruct later on in the course.
Understanding MapStruct
The generated code performs direct property mapping, avoiding the performance overhead of
reflection-based mapping frameworks.
MapStruct integrates with Lombok and Spring, making it an ideal choice for our blog application, but
it will need a little configuring.
Adding Dependencies
First, we need to add MapStruct dependencies to our pom.xml file. Note we're specifying the Lombok
version here -- we need to do this to make sure the versions of MapStruct and Lombok we have are
compatible.
<properties>
<org.mapstruct.version>1.6.3</org.mapstruct.version>
<lombok.version>1.18.36</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
The Maven compiler plugin needs special configuration to work with both MapStruct and Lombok:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
That's all we'll need to do for the moment. We can now use MapStruct to generate DTOs when we
need them later on in the build!
Summary
Once we’re here, we should install the necessary dependencies using the following command:
npm install
Once everything is installed, we’ll run the app with the following command:
And our backend is running, although as we’ve not yet implemented the necessary endpoints on the
backend it’s not working completely just yet!
As we build out our backend we should see the frontend starting to resembled the demo earlier in
the course.
Summary
A JPA entity is a persistent domain object that represents a table in our database.
The entity class must be annotated with @Entity and follow certain conventions to work correctly
with JPA.
Hibernate (JPA implementation) will use this class to automatically create the corresponding
database table and manage the mapping between Java objects and database records.
The User entity needs to store essential information about each user in our system.
package com.devtiro.blog.domain.entities;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@Column(nullable = false)
@Override
public int hashCode() {
return Objects.hash(id, email, password, name, createdAt);
}
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
Each field in our User entity serves a specific purpose and has constraints.
The @Column annotations ensure data integrity by specifying that email must be unique and that
essential fields cannot be null.
We use UUID instead of sequential IDs for better security and scalability.
Lifecycle Callbacks
When a new user is created, JPA will automatically call our onCreate() method to set the creation
timestamp.
This ensures we always have an accurate record of when each user account was created.
Summary
• The User entity forms the foundation for authentication and user management
• JPA annotations map Java objects to database tables automatically
• UUID primary keys provide better security than sequential IDs
• Custom equals and hashCode methods ensure proper entity behavior
• Lifecycle callbacks automate administrative field updates
Blog categories serve as high-level containers that group related content together.
Categories differ from tags in that they represent broad, predefined classifications rather than
flexible, user-defined keywords.
package com.devtiro.blog.domain.entities;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
Summary
Tags represent keywords or phrases that describe specific aspects of blog content.
This flexibility allows content creators to precisely describe their posts and helps readers find exactly
what they're looking for.
package com.devtiro.blog.domain.entities;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "tags")
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
The unique constraint on the name field ensures we don't have duplicate tags in our system.
Summary
These classes will enable us to manage blog posts throughout their lifecycle, from draft to
publication.
A post status enum helps track the editorial state of each blog post in our system.
package com.devtiro.blog.domain;
The Post entity represents individual blog entries and must store all content and metadata.
package com.devtiro.blog.domain.entities;
import com.devtiro.blog.PostStatus;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private PostStatus status;
@Column(nullable = false)
private Integer readingTime;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Post post = (Post) o;
return Objects.equals(id, post.id) &&
Objects.equals(title, post.title) &&
Objects.equals(content, post.content) &&
status == post.status &&
Objects.equals(readingTime, post.readingTime) &&
Objects.equals(createdAt, post.createdAt) &&
Objects.equals(updatedAt, post.updatedAt);
}
@Override
public int hashCode() {
return Objects.hash(id, title, content, status, readingTime,
createdAt, updatedAt);
}
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
The columnDefinition = "TEXT" annotation ensures our database can store long-form blog content
without size restrictions.
The @Enumerated(EnumType.STRING) annotation stores the post status as a readable string rather
than a numeric value.
Summary
A blog post must always have an author, and this relationship needs to be enforced at the database
level.
@Entity
@Table(name = "posts")
public class Post {
// ... existing fields ...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id", nullable = false)
private User author;
@Entity
@Table(name = "users")
public class User {
// ... existing fields ...
@OneToMany(mappedBy = "author")
private List<Post> posts = new ArrayList<>();
The FetchType.LAZY configuration optimizes performance by loading post authors only when
explicitly accessed.
This is particularly important for list operations where we might load many posts but not need their
author details immediately.
Let's now define the cascades for this relationship. Cascades define how changes to one entity affect
the other.
As a Post should not be able to change its author, we'll not add cascades to the Post side of the
relationship.
However, we'll want a user's posts to be deleted when we delete that user, so we'll add cascades to
the User side of the relationship:
@Entity
@Table(name = "users")
public class User {
// ... existing fields ...
Adding the cascade type of ALL here will ensure that a user's posts are deleted when that user is
deleted.
It will also allow us to save new a Post if we add it to the posts collection and save the User.
Summary
Each post in our blog system must belong to exactly one category, establishing a mandatory one-to-
many relationship.
@Entity
@Table(name = "posts")
public class Post {
// ... existing fields ...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
@Entity
@Table(name = "categories")
public class Category {
// ... existing fields ...
@OneToMany(mappedBy = "category")
private List<Post> posts = new ArrayList<>();
The FetchType.LAZY configuration on the post side optimizes performance by loading category
details only when explicitly accessed.
The initialization of the posts collection with new ArrayList<>() prevents null pointer exceptions
when accessing the collection.
We deliberately avoid cascade operations in this relationship because posts and categories have
independent lifecycles.
Deleting a category shouldn't automatically delete its posts, as they might need to be reassigned to
another category.
Summary
• Posts must belong to exactly one category, enforced by a non-nullable foreign key
• Cascade operations are intentionally omitted to maintain data integrity
• Relationship validation occurs in the service layer rather than the entity level
Introduction
In our previous lessons, we established relationships between posts and both users and categories.
Now we'll implement a many-to-many relationship between posts and tags, enabling flexible content
classification.
This relationship will allow each post to have multiple tags and each tag to be associated with
multiple posts.
A many-to-many relationship requires a join table in the database to maintain the associations
between entities.
@Entity
@Table(name = "posts")
public class Post {
// ... existing fields ...
@ManyToMany
@JoinTable(
name = "post_tags",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
The Tag entity needs to maintain its side of the bidirectional relationship.
@Entity
@Table(name = "tags")
public class Tag {
// ... existing fields ...
@ManyToMany(mappedBy = "tags")
private Set<Post> posts = new HashSet<>();
Cascade Considerations
Tags and posts have independent lifecycles - deleting a post shouldn't delete its tags, and deleting a
tag shouldn't delete associated posts.
We use Set instead of List for both sides of the relationship for several reasons.
Set operations are generally more efficient when dealing with large collections.
The order of tags or posts in the collections isn't meaningful for our use case.
Summary
• Many-to-many relationships require a join table to maintain associations between posts and tags
• Using Set instead of List prevents duplicates and improves performance
• Omitting cascade operations preserves independent entity lifecycles
• Bidirectional relationships enable efficient navigation in both directions
Now we'll create a repository interface that will handle data access operations for users.
This repository will serve as the foundation for user authentication and management features we'll
build later.
Spring Data JPA repositories provide a powerful abstraction that eliminates the need to write basic
CRUD operations manually.
The JpaRepository interface offers built-in methods for common operations like saving, finding, and
deleting entities.
By extending JpaRepository, our custom repository inherits these methods while maintaining the
ability to add specialized queries as needed.
package com.devtiro.blog.repositories;
import com.devtiro.blog.domain.entities.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
}
The @Repository annotation marks this interface as a Spring Data repository component.
The generic parameters <User, UUID> tell Spring Data that this repository manages User entities
with UUID primary keys.
This configuration aligns with our User entity design from the previous lessons.
Repository Benefits
Spring Data JPA will automatically implement this interface at runtime, providing methods like:
• save(User entity)
• findById(UUID id)
• findAll()
These methods handle the underlying SQL generation and execution, significantly reducing
boilerplate code.
Summary
This repository will enable efficient category management and form the foundation for content
organization features.
Spring Data JPA repositories handle the complex task of persisting and retrieving category data.
The repository needs to work with our Category entity, which we created earlier to represent content
classifications.
By leveraging Spring Data JPA's features, we can focus on business logic rather than data access
implementation.
package com.devtiro.blog.repositories;
import com.devtiro.blog.domain.entities.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface CategoryRepository extends JpaRepository<Category, UUID> {
}
Summary
This repository will enable efficient tag management and support the flexible content classification
system we're building.
Spring Data JPA provides powerful abstractions for handling tag persistence and retrieval.
The repository needs to work with our Tag entity, which represents the keywords and phrases used
to classify blog content.
We'll leverage Spring Data JPA's built-in features to handle common operations like creating, reading,
updating, and deleting tags.
package com.devtiro.blog.repositories;
import com.devtiro.blog.domain.entities.Tag;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface TagRepository extends JpaRepository<Tag, UUID> {
}
Summary
This repository will enable efficient post management and serve as the foundation for our content
management features.
Spring Data JPA repositories handle the complex task of persisting and retrieving blog post data.
The repository needs to work with our Post entity, which represents the primary content in our blog
platform.
By leveraging Spring Data JPA's features, we can focus on implementing business logic rather than
data access details.
package com.devtiro.blog.repositories;
import com.devtiro.blog.domain.entities.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface PostRepository extends JpaRepository<Post, UUID> {
}
Summary
We'll implement this functionality layer by layer, starting with the controller and working through to
the service implementation.
This endpoint will serve as a foundation for category management and content organization in our
blog platform.
The controllers package will house all our REST API endpoints.
src/main/java/com/devtiro/blog/controllers/
package com.devtiro.blog.controllers;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/categories")
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
@GetMapping
public ResponseEntity<List<CategoryDto>> listCategories() {
// TODO
}
}
DTOs (Data Transfer Objects) separate our API representation from internal domain models.
src/main/java/com/devtiro/blog/domain/dtos/
The CategoryDto represents the category data that will be sent to clients.
package com.devtiro.blog.domain.dtos;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CategoryDto {
private UUID id;
private String name;
private long postCount;
}
The services package will contain our business logic interfaces and implementations.
src/main/java/com/devtiro/blog/services/
package com.devtiro.blog.services;
import com.devtiro.blog.domain.entities.Category;
import java.util.List;
package com.devtiro.blog.services.impl;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CategoryServiceImpl implements CategoryService {
private final CategoryRepository categoryRepository;
@Override
public List<Category> listCategories() {
return categoryRepository.findAllWithPostCount();
}
}
package com.devtiro.blog.repositories;
import com.devtiro.blog.domain.entities.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface CategoryRepository extends JpaRepository<Category, UUID> {
@Query("SELECT c FROM Category c LEFT JOIN FETCH c.posts")
List<Category> findAllWithPostCount();
}
Now we can get a list of Category objects, but how do we convert them into CategoryDto objects?
Let's cover that in the next lesson.
Summary
We'll implement a MapStruct mapper to handle this conversion efficiently, completing our category
listing endpoint.
This will enable us to properly format our category data for API responses while maintaining clean
separation between our domain and API layers.
MapStruct mappers require their own dedicated package for organization and clarity.
In this package, we'll house all our mapper interfaces that handle entity-to-DTO conversions.
The CategoryMapper interface defines how to convert between Category entities and CategoryDto
objects:
package com.devtiro.blog.mappers;
import com.devtiro.blog.domain.PostStatus;
import com.devtiro.blog.domain.dtos.CategoryDto;
import com.devtiro.blog.domain.entities.Category;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import org.mapstruct.ReportingPolicy;
@Named("calculatePostCount")
default long calculatePostCount(java.util.Set<com.devtiro.blog.domain.entities.Post> posts) {
if (posts == null) {
return 0;
}
return posts.stream()
.filter(post -> PostStatus.PUBLISHED.equals(post.getStatus()))
.count();
}
}
Now we can use our mapper to convert entities to DTOs in the controller:
@RestController
@RequestMapping("/api/v1/categories")
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
private final CategoryMapper categoryMapper;
@GetMapping
public ResponseEntity<List<CategoryDto>> listCategories() {
List<Category> categories = categoryService.listCategories();
return ResponseEntity.ok(
categories.stream().map(categoryMapper::toDto).toList()
);
}
}
You should see a JSON response containing a list of categories with their IDs, names, and post
counts.
Summary
This functionality will enable content organization by allowing users to define new category
groupings for blog posts.
The create endpoint will validate inputs and prevent duplicate categories, ensuring data integrity in
our blog platform.
The create category request requires validation to ensure data quality and consistency.
Let's create the CreateCategoryRequest class to define the structure and validation rules:
package com.devtiro.blog.domain.dtos;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateCategoryRequest {
@NotBlank(message = "Category name is required")
@Size(min = 2, max = 50, message = "Category name must be between {min} and {max} characters")
@Pattern(regexp = "^[\\w\\s-]+$", message = "Category name can only contain letters, numbers, spac
private String name;
}
Mapper Extension
The service layer must handle the business logic of creating categories while preventing duplicates.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CategoryServiceImpl implements CategoryService {
private final CategoryRepository categoryRepository;
@Override
@Transactional
public Category createCategory(Category category) {
// Check if category with same name already exists
if (categoryRepository.existsByNameIgnoreCase(category.getName())) {
throw new IllegalArgumentException("Category already exists with name: " + category.getNam
}
return categoryRepository.save(category);
}
}
Repository Enhancement
@Repository
public interface CategoryRepository extends JpaRepository<Category, UUID> {
// ... existing methods ...
Controller Implementation
Finally, we can implement the controller method to handle category creation requests:
@RestController
@RequestMapping("/api/v1/categories")
@RequiredArgsConstructor
@PostMapping
public ResponseEntity<CategoryDto> createCategory(
@Valid @RequestBody CreateCategoryRequest createCategoryRequest) {
Category category = categoryMapper.toEntity(createCategoryRequest);
Category savedCategory = categoryService.createCategory(category);
return new ResponseEntity<>(
categoryMapper.toDto(savedCategory),
HttpStatus.CREATED
);
}
}
Summary
Now we'll create a centralized error handling system to provide consistent, user-friendly error
responses across our API.
This error handling system will ensure our frontend can display meaningful messages to users when
things go wrong.
Spring's @ControllerAdvice annotation enables global exception handling across all our controllers.
package com.devtiro.blog.controllers;
import com.devtiro.blog.domain.dtos.ApiErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
@RestController
@ControllerAdvice
@Slf4j
public class ErrorController {
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleException(Exception ex) {
log.error("Caught exception", ex);
ApiErrorResponse error = ApiErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.message("An unexpected error occurred")
.build();
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Our API needs a standardized error response format to ensure consistent error handling across all
endpoints.
package com.devtiro.blog.domain.dtos;
import lombok.AllArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiErrorResponse {
private int status;
private String message;
private List<FieldError> errors;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class FieldError {
private String field;
private String message;
}
}
@RestController
@ControllerAdvice
@Slf4j
public class ErrorController {
// ... existing methods ...
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiErrorResponse> handleIllegalArgumentException(
IllegalArgumentException ex) {
ApiErrorResponse error = ApiErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST.value())
.message(ex.getMessage())
.build();
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
}
Summary
Spring Boot provides excellent support for using different databases in different environments.
The H2 database is an excellent choice for testing because it runs in memory and doesn't require
external setup.
This approach ensures our tests are fast, reliable, and truly isolated from the production
environment.
docker-compose down
./mvnw test
The test failure occurs because our application tries to connect to PostgreSQL, which isn't available:
# JPA Configuration
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
The jdbc:h2:mem:testdb URL specifies an in-memory database that's created fresh for each test run.
The create-drop setting ensures our database schema is created before tests and dropped afterward,
providing a clean slate for each test.
Spring Boot automatically prioritizes test-specific properties files during test execution.
The tests should now pass, using the H2 database instead of PostgreSQL.
Summary
• The create-drop setting ensures a clean test database for each test run
This functionality will maintain data integrity by preventing the deletion of categories that still
contain posts.
The delete endpoint ensures our blog platform remains organized while protecting against accidental
data loss.
Controller Implementation
The category deletion process begins in our controller layer with a new endpoint definition.
@RestController
@RequestMapping("/api/v1/categories")
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCategory(@PathVariable UUID id) {
categoryService.deleteCategory(id);
return ResponseEntity.noContent().build();
}
}
Service Interface
The service layer defines the contract for category deletion through a new method.
Service Implementation
The service implementation enforces our business rules, particularly preventing deletion of
categories with associated posts.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CategoryServiceImpl implements CategoryService {
private final CategoryRepository categoryRepository;
@Override
@Transactional
public void deleteCategory(UUID id) {
categoryRepository.delete(category);
}
Our error controller needs to handle the new IllegalStateException that could occur during category
deletion.
@RestController
@ControllerAdvice
@Slf4j
public class ErrorController {
// ... existing methods ...
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ApiErrorResponse> handleIllegalStateException(
IllegalStateException ex) {
ApiErrorResponse error = ApiErrorResponse.builder()
.status(HttpStatus.CONFLICT.value())
.message(ex.getMessage())
.build();
return new ResponseEntity<>(error, HttpStatus.CONFLICT);
}
}
Summary
Now we'll add security to our blog platform, ensuring that certain operations are protected while
keeping our content publicly readable.
This will enable us to implement user authentication and protected endpoints in the following
lessons.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
By default, Spring Security requires authentication for all endpoints, which we'll need to customize
for our public-facing blog content.
Security configuration in Spring Boot 3.x uses a more modern, functional approach compared to
earlier versions.
Let's create a new package com.devtiro.blog.config and implement our intitial SecurityConfig class:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exce
return config.getAuthenticationManager();
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/categories/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/tags/**").permitAll()
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}
Summary
Now we'll implement user loading functionality to enable authentication based on our existing User
entity.
This implementation will bridge the gap between Spring Security's user management and our
domain model.
The BlogUserDetails class adapts our domain User entity to Spring Security's authentication system:
package com.devtiro.blog.security;
import com.devtiro.blog.domain.entities.User;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Getter
@RequiredArgsConstructor
public class BlogUserDetails implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
The UserRepository needs a method to find users by their email addresses as that's what the user
will use to log in:
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
}
The BlogUserDetailsService loads user data and converts it to Spring Security's UserDetails format:
package com.devtiro.blog.services.impl;
import com.devtiro.blog.domain.BlogUserDetails;
import com.devtiro.blog.domain.entities.User;
import com.devtiro.blog.repositories.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
public class BlogUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiErrorResponse> handleBadCredentialsException(BadCredentialsException ex) {
ApiErrorResponse error = ApiErrorResponse.builder()
.status(HttpStatus.UNAUTHORIZED.value())
.message("Incorrect username or password")
.build();
return new ResponseEntity<>(error, HttpStatus.UNAUTHORIZED);
}
Summary
This endpoint will validate user credentials and return a token for subsequent authenticated
requests.
This foundation enables secure access to protected resources in our blog platform.
The authentication process requires dedicated DTOs to handle login requests and responses
securely:
package com.devtiro.blog.domain.dtos;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
private String email;
private String password;
}
The response DTO includes the JWT token and its expiration time:
package com.devtiro.blog.domain.dtos;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
private String token;
private long expiresIn;
}
import org.springframework.security.core.userdetails.UserDetails;
The controller handles incoming authentication requests and produces JWT tokens:
package com.devtiro.blog.controllers;
import com.devtiro.blog.domain.dtos.AuthResponse;
import com.devtiro.blog.domain.dtos.LoginRequest;
import com.devtiro.blog.services.AuthenticationService;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/auth")
@AllArgsConstructor
public class AuthController {
private final AuthenticationService authenticationService;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest loginRequest) {
UserDetails user = authenticationService.authenticate(
loginRequest.getEmail(),
loginRequest.getPassword()
);
return ResponseEntity.ok(authResponse);
}
}
Summary
Now we'll implement the authentication service to handle user verification and JWT token
generation.
This implementation will enable secure user authentication and stateless session management
across our application.
The AuthenticationServiceImpl class forms the core of our authentication logic, integrating with
Spring Security's authentication manager:
package com.devtiro.blog.services.impl;
import com.devtiro.blog.services.AuthenticationService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AuthenticationServiceImpl implements AuthenticationService {
@Value("${jwt.secret}")
private String secretKey;
@Override
public UserDetails authenticate(String email, String password) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(email, password)
@Override
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + jwtExpiryMs))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
@Override
public UserDetails validateToken(String token) {
String username = extractUsername(token);
return userDetailsService.loadUserByUsername(username);
}
Update Properties
Let's not forget to add a secret to our properties file. It needs to be at least 32 bytes long!
jwt.secret=your-256-bit-secret-key-here-make-it-at-least-32-bytes-long
Summary
This filter enables stateless authentication by validating tokens and establishing user context for
each request.
@Override
public UserDetails validateToken(String token) {
String username = extractUsername(token);
return userDetailsService.loadUserByUsername(username);
}
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
try {
String token = extractToken(request);
if (token != null) {
UserDetails userDetails = authenticationService.validateToken(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(
AuthenticationService authenticationService) {
return new JwtAuthenticationFilter(authenticationService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
return http.build();
}
Summary
First, let's declare the UserDetailsService bean in SecurityConfig, but let's also add a check, creating
a user if they don't exist:
@Configuration
public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
BlogUserDetailsService blogUserDetailsService = new BlogUserDetailsService(userRepository);
return blogUserDetailsService;
}
}
With this we should now be able to log in to our application using the credentials [email protected] and
password.
Summary
This endpoint will enable users to explore blog content through tag-based navigation and filtering.
A dedicated response DTO ensures consistent representation of tag data in our API:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TagResponse {
private UUID id;
private String name;
private Integer postCount;
}
The TagMapper interface handles the conversion between our domain entities and response DTOs:
@Named("calculatePostCount")
default Integer calculatePostCount(Set<Post> posts) {
if (posts == null) {
return 0;
}
return (int) posts.stream()
.filter(post -> PostStatus.PUBLISHED.equals(post.getStatus()))
.count();
}
}
Repository Enhancement
The TagRepository needs a method to efficiently fetch tags with their post counts:
@Repository
public interface TagRepository extends JpaRepository<Tag, UUID> {
@Query("SELECT t FROM Tag t LEFT JOIN FETCH t.posts")
The TagServiceImpl implements the tag retrieval logic with proper transaction management:
@Service
@RequiredArgsConstructor
public class TagServiceImpl implements TagService {
private final TagRepository tagRepository;
@Override
@Transactional(readOnly = true)
public List<Tag> getAllTags() {
return tagRepository.findAllWithPostCount();
}
}
Controller Implementation
@RestController
@RequestMapping("/api/v1/tags")
@RequiredArgsConstructor
public class TagController {
private final TagService tagService;
private final TagMapper tagMapper;
@GetMapping
public ResponseEntity<List<TagResponse>> getAllTags() {
List<Tag> tags = tagService.getAllTags();
return ResponseEntity.ok(tagMapper.toTagResponseList(tags));
}
}
Summary
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateTagsRequest {
Repository Enhancement
The TagRepository needs specialized methods for efficient tag lookup and creation:
@Repository
public interface TagRepository extends JpaRepository<Tag, UUID> {
Set<Tag> findByNameInIgnoreCase(Set<String> names);
}
@Transactional
@Override
public List<Tag> createTags(Set<String> tagNames) {
List<Tag> existingTags = tagRepository.findByNameIn(tagNames);
savedTags.addAll(existingTags);
return savedTags;
}
Controller Implementation
The TagController handles the HTTP POST requests for tag creation:
@RestController
@RequestMapping("/api/v1/tags")
@RequiredArgsConstructor
public class TagController {
private final TagService tagService;
private final TagMapper tagMapper;
@PostMapping
public ResponseEntity<List<TagDto>> createTags(@RequestBody CreateTagsRequest createTagsRequest) {
List<Tag> savedTags = tagService.createTags(createTagsRequest.getNames());
List<TagDto> createdTagRespons = savedTags.stream().map(tagMapper::toTagResponse).toList();
return new ResponseEntity<>(
createdTagRespons,
HttpStatus.CREATED
);
}
}
Summary
The tag deletion process requires careful validation to prevent orphaned content:
@Transactional
@Override
public void deleteTag(UUID id) {
tagRepository.findById(id).ifPresent(tag -> {
if(!tag.getPosts().isEmpty()) {
throw new IllegalStateException("Cannot delete tag with posts");
}
tagRepository.deleteById(id);
});
}
Controller Implementation
The TagController handles HTTP DELETE requests with proper response codes:
@DeleteMapping(path = "/{id}")
public ResponseEntity<Void> deleteTag(@PathVariable UUID id) {
tagService.deleteTag(id);
return ResponseEntity.noContent().build();
}
Summary
The feature enables content discovery and forms the foundation for our blog's public interface.
The PostDto encapsulates all necessary post information for API responses:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PostDto {
private UUID id;
private String title;
private String content;
private AuthorDto author;
private CategoryDto category;
private Set<TagDto> tags;
private Integer readingTime;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private PostStatus status;
}
⚠ Warning
I use postStatus as the instance variable for this class, which causes some troubleshooting later!
To avoid this use status.
Repository Implementation
@Repository
public interface PostRepository extends JpaRepository<Post, UUID> {
List<Post> findAllByStatusAndCategoryAndTagsContaining(PostStatus status, Category category, Tag t
List<Post> findAllByStatusAndCategory(PostStatus status, Category category);
List<Post> findAllByStatusAndTagsContaining(PostStatus status, Tag tag);
List<Post> findAllByStatus(PostStatus status);
}
@Service
@RequiredArgsConstructor
public class PostServiceImpl implements PostService {
@Override
@Transactional(readOnly = true)
public List<Post> getAllPosts(UUID categoryId, UUID tagId) {
if(categoryId != null && tagId != null) {
Category category = categoryService.getCategoryById(categoryId);
Tag tag = tagService.getTagById(tagId);
return postRepository.findAllByStatusAndCategoryAndTagsContaining(
PostStatus.PUBLISHED,
category,
tag
);
}
if(categoryId != null) {
Category category = categoryService.getCategoryById(categoryId);
return postRepository.findAllByStatusAndCategory(
PostStatus.PUBLISHED,
category
);
}
if(tagId != null) {
Tag tag = tagService.getTagById(tagId);
return postRepository.findAllByStatusAndTagsContaining(
PostStatus.PUBLISHED,
tag
);
}
return postRepository.findAllByStatus(PostStatus.PUBLISHED);
}
}
Mapper Configuration
The PostController serves as the entry point for all post-related operations:
@RestController
@RequestMapping(path = "/api/v1/posts")
@RequiredArgsConstructor
public class PostController {
@GetMapping
public ResponseEntity<List<PostDto>> getAllPosts(
@RequestParam(required = false) UUID categoryId,
@RequestParam(required = false) UUID tagId) {
List<Post> posts = postService.getAllPosts(categoryId, tagId);
List<PostDto> postDtos = posts.stream().map(postMapper::toDto).toList();
return ResponseEntity.ok(postDtos);
}
Exception Handling
@RestController
@ControllerAdvice
@Slf4j
public class ErrorController {
// ...
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleEntityNotFoundException(EntityNotFoundException ex)
ApiErrorResponse error = ApiErrorResponse.builder()
.status(HttpStatus.NOT_FOUND.value())
.message(ex.getMessage())
.build();
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
}
Summary
• Created flexible post retrieval endpoint with optional category and tag filtering
• Implemented comprehensive DTO structure for post representation
• Added repository methods for efficient post querying
• Established service layer with proper transaction management
• Configured MapStruct mapper for clean entity-to-DTO conversion
@Repository
public interface PostRepository extends JpaRepository<Post, UUID> {
// ... existing methods ...
List<Post> findAllByAuthorAndStatus(User author, PostStatus status);
}
Service Implementation
@Service
@RequiredArgsConstructor
public class PostServiceImpl implements PostService {
//...
private final PostRepository postRepository;
@Override
public List<Post> getDraftPosts(User user) {
return postRepository.findAllByAuthorAndStatus(user, PostStatus.DRAFT);
}
}
The draft posts endpoint requires careful handling of user authentication to maintain content
security:
@GetMapping(path = "/drafts")
public ResponseEntity<List<PostDto>> getDrafts(@RequestAttribute UUID userId) {
User loggedInUser = userService.getUserById(userId);
List<Post> draftPosts = postService.getDraftPosts(loggedInUser);
List<PostDto> postDtos = draftPosts.stream().map(postMapper::toDto).toList();
// ...
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/v1/auth/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/drafts").authenticated()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/categories/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/tags/**").permitAll()
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
).addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
Summary
The CreatePostRequestDto class defines the structure and validation rules for incoming post creation
requests:
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CreatePostRequestDto {
@Builder.Default
@Size(max = 10, message = "Maximum {max} tags allowed")
private Set<UUID> tagIds = new HashSet<>();
The CreatePostRequest class serves as our internal domain model for post creation:
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CreatePostRequest {
private String title;
@Builder.Default
private Set<UUID> tagIds = new HashSet<>();
Mapper Configuration
The PostMapper interface handles the conversion between DTOs and domain objects:
Service Implementation
@Override
@Transactional
public Post createPost(User user, CreatePostRequest createPostRequest) {
Post newPost = new Post();
newPost.setTitle(createPostRequest.getTitle());
newPost.setContent(createPostRequest.getContent());
newPost.setStatus(createPostRequest.getStatus());
newPost.setAuthor(user);
newPost.setReadingTime(calculateReadingTime(createPostRequest.getContent()));
return postRepository.save(newPost);
}
Controller Implementation
The PostController handles HTTP POST requests for creating new posts:
@PostMapping
public ResponseEntity<PostDto> createPost(
Summary
The UpdatePostRequestDto defines the structure and validation rules for post updates:
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UpdatePostRequestDto {
@Builder.Default
@Size(max = 10, message = "Maximum {max} tags allowed")
private Set<UUID> tagIds = new HashSet<>();
The UpdatePostRequest class serves as our internal model for post updates:
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UpdatePostRequest {
@Builder.Default
private Set<UUID> tagIds = new HashSet<>();
Mapper Implementation
The PostServiceImpl handles the complex logic of updating posts with proper validation:
@Override
@Transactional
public Post updatePost(UUID id, UpdatePostRequest updatePostRequest) {
Post existingPost = postRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Post does not exist with id " + id));
existingPost.setTitle(updatePostRequest.getTitle());
String postContent = updatePostRequest.getContent();
existingPost.setContent(postContent);
existingPost.setStatus(updatePostRequest.getStatus());
existingPost.setReadingTime(calculateReadingTime(postContent));
return postRepository.save(existingPost);
}
Controller Implementation
The PostController handles the HTTP PUT requests for post updates:
@PutMapping(path = "/{id}")
public ResponseEntity<PostDto> updatePost(
@PathVariable UUID id,
@Valid @RequestBody UpdatePostRequestDto updatePostRequestDto) {
UpdatePostRequest updatePostRequest = postMapper.toUpdatePostRequest(updatePostRequestDto);
Post updatedPost = postService.updatePost(id, updatePostRequest);
PostDto updatedPostDto = postMapper.toDto(updatedPost);
return ResponseEntity.ok(updatedPostDto);
}
Summary
The PostService interface needs to define the contract for post retrieval:
Service Implementation
@Override
public Post getPost(UUID id) {
return postRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Post does not exist with ID " + id));
}
@GetMapping(path = "/{id}")
public ResponseEntity<PostDto> getPost(
@PathVariable UUID id
) {
Post post = postService.getPost(id);
PostDto postDto = postMapper.toDto(post);
return ResponseEntity.ok(postDto);
}
Summary
The deletion process ensures proper cleanup of relationships while maintaining data integrity across
the platform.
The PostService interface needs to define the contract for post deletion:
Service Implementation
The PostServiceImpl handles the core logic of post deletion with proper validation:
@Transactional
@Override
public void deletePost(UUID id) {
Post post = getPost(id);
postRepository.delete(post);
}
⚠ Warning
The video misses out the @Transactional annotation on deletePost. Please note that this method
would benefit @Transactional, so it's worth adding!
The post deletion endpoint requires user authentication and proper error handling to maintain
security:
@DeleteMapping(path = "/{id}")
public ResponseEntity<Void> deletePost(@PathVariable UUID id) {
postService.deletePost(id);
return ResponseEntity.noContent().build();
}
Summary
Let's take a moment to review what we've covered and talk about ways to make it even better.
Our blog platform includes the core features needed for content management:
Next Steps
While our platform has the basics, there are some key areas to improve on:
Security
The authentication system could use refresh tokens to improve user session handling.
Adding CSRF protection would help prevent cross-site request forgery attacks.
User Experience
Better error messages would help users understand and fix issues more quickly.
Summary
• You've built a solid foundation with the essential features of a blog platform
• The next steps focus on moving from basic functionality to production readiness