Skip to content

Project Structure

Getting a preview of the structure of a typical spring boot is a great way to get a head start on developing a project. Before you begin your first project, it's helpful to know where you will be creating classes, and a workflow for your development.

File Structure

├── [Project Name]
│   ├── .gradle
│   ├── build
│   ├── src
│   │   ├── main
│   │   │   ├── java
│   │   │   │   ├── com.thomaswildetech.structure
│   │   │   │   │   ├── config
│   │   │   │   │   │   ├── models
│   │   │   │   │   │   │   └── ErrorResponse.java
│   │   │   │   │   │   ├── security
│   │   │   │   │   │   ├── InitializeDbData.java
│   │   │   │   │   │   ├── JsonConfig.java
│   │   │   │   │   │   ├── RestExceptionHandler.java
│   │   │   │   │   │   └── SchedulingConfig.java
│   │   │   │   │   ├── controllers
│   │   │   │   │   │   └── league
│   │   │   │   │   │       └── PlayerController.java
│   │   │   │   │   ├── entities
│   │   │   │   │   │   ├── awards
│   │   │   │   │   │   │   ├── Award.java
│   │   │   │   │   │   │   └── AwardReceived.java
│   │   │   │   │   │   ├── league
│   │   │   │   │   │   │   ├── Coach.java
│   │   │   │   │   │   │   ├── Player.java
│   │   │   │   │   │   │   ├── Position.java
│   │   │   │   │   │   │   └── Team.java
│   │   │   │   │   │   └── schedule
│   │   │   │   │   │       └── Match.java
│   │   │   │   │   ├── repositories
│   │   │   │   │   │   └── league
│   │   │   │   │   │       └── PlayerRepository.java
│   │   │   │   │   ├── scheduled
│   │   │   │   │   ├── services
│   │   │   │   │   │   └── league
│   │   │   │   │   │       └── PlayerRepository.java
│   │   │   │   │   └── StructureApplication.java
│   │   │   └── resources
│   │   │       ├── application.properties
│   │   │       ├── application-api.properties
│   │   │       ├── application-local.properties
│   │   │       ├── application-scheduling.properties
│   │   │       └── data.sql
│   │   └── test
│   ├── .gitignore
│   ├── build.gradle
│   └── settings.json
└──

Entities

Entity classes which have the are POJOs (plain old java objects) that map to database tables and whose properties map to the columns of that table. It is the basis for what is considered an ORM (object relational mapping), in which programmatic instances of these classes map directly to and entry in the table and whose changes to their properties get reflected in the database.

Player Entity
Player.java
    @Entity // 
    @Table(name = "lu_player", // 
        indexes = {
            @Index(columnList = "firstName, lastName", name = "idx_lu_player_first_name"),
            @Index(columnList = "lastName, firstName", name = "idx_lu_player_last_name"),
            @Index(columnList = "position_id", name = "idx_lu_player_position"),
            @Index(columnList = "team_id", name = "idx_lu_player_team"),
        }
    )
    public class Player {

        @Id // 
        @GeneratedValue(strategy = GenerationType.IDENTITY) // 
        private Long id; // 

        @Column(length = 50) // 
        private String firstName;

        @Column(length = 50)
        private String lastName;

        @ManyToOne(fetch = FetchType.EAGER) // 
        @JoinColumn(name = "position_id", referencedColumnName = "id", foreignKey = @ForeignKey(name = "FK_PLAYER_TO_POSITION")) // 
        private Position position;

        @ManyToOne
        @JoinColumn(name = "team_id", referencedColumnName = "id", foreignKey = @ForeignKey(name = "FK_PLAYER_TO_TEAM"))
        @JsonIgnoreProperties("roster")
        private Team team;

        @OneToMany(mappedBy = "player"
                , cascade = {CascadeType.PERSIST, CascadeType.MERGE}
        )
        // Ensure at the database level that if a player is deleted, all of their awards are also deleted
        @OnDelete(action = OnDeleteAction.CASCADE)
        @JsonIgnoreProperties("player")
        private List<AwardReceived> receivedAwards;

        @ManyToMany(
                cascade = {CascadeType.PERSIST, CascadeType.MERGE}
        )
        @JoinTable(
                name = "j_player_team",
                joinColumns = @JoinColumn(name = "player_id", referencedColumnName = "id", foreignKey = @ForeignKey(name = "FK_PLAYER_TEAM_TO_PLAYER")),
                inverseJoinColumns = @JoinColumn(name = "team_id", referencedColumnName = "id", foreignKey = @ForeignKey(name = "FK_PLAYER_TEAM_TO_TEAM"))
        )
        // Spring knows to delete the join table records when a player is deleted
        // However, without an OnDelete Cascade, you cannot delete a player from the database because you will get a FK constrain violation
        @OnDelete(action = OnDeleteAction.CASCADE)
        private List<Team> teamsPlayedFor;

        private Boolean franchiseTagged;

        @Transient
        @JsonIgnore
        private String someTempVariable;
        // Getters and Setters
    }

Major Topics

  • Primary key and generated value
  • Relationships to other tables (foreign keys)

    • @OneToOne
    • @ManyToOne
    • @OneToMany
    • @ManyToMany
  • Cascading actions taken by Spring

  • @OnDelete foreign key constraint actions

Repositories

Repositories are typed interfaces which are responsible for all database transactions for a particular entity. So if you want to be able to make changes to a Player entity, you would want to have a repository dedicated to Player, likely called PlayerRepository. Spring Boot already have default implementations for methods such as findById(ID id), findAll() save(T entity), and delete(). SpringBoot also makes it very easy to create custom repository methods.

Player Repository
@Repository
public interface PlayerRepository extends JpaRepository<Player, Long> {

    @Override
    @EntityGraph(attributePaths = {"team", "position"})  // Can't fetch mutliple "bags" (lists) with EntityGraph
    List<Player> findAll();

    @Override
    @EntityGraph(attributePaths = {"team", "position", "teamsPlayedFor", "receivedAwards", "receivedAwards.award"}) // Can fetch multiple "bags" when returning only 1 result
    Optional<Player> findById(Long id);
}

Major Topics

  • Usage of @EntityGraph to dictate fetching of properties that relate to other tables.
  • Custom methods using method naming convention.
  • Custom methods using @Query.
  • Option to use JDBC instead

Services

Services are where all "business logic" will reside. This is where data should be extracted from repositories and any further data manipulation should be made. Generally, you will likely repeat any methods created in a repository here in Services, and allowing you perform additional logic which is usually necessary in JPA transactions as it is.

Player Service
@Service
public class PlayerService {

    private final PlayerRepository playerRepository;

    private final AwardRepository awardRepository;

    private final TeamRepository teamRepository;

    @Autowired
    public PlayerService(PlayerRepository playerRepository, AwardRepository awardRepository, TeamRepository teamRepository) {
        this.playerRepository = playerRepository;
        this.awardRepository = awardRepository;
        this.teamRepository = teamRepository;
    }

    public List<Player> findAll() {
        return playerRepository.findAll();
    }

    public Optional<Player> findById(Long id){
        return playerRepository.findById(id);
    }

    public Player save(Player player) {

        // If we are cascading/persisting new received awards, we need to fetch the award from the database
        if(player.getReceivedAwards() != null){
            player.getReceivedAwards().forEach(awardReceived -> {
                Optional<Award> award = awardRepository.findById(awardReceived.getAward().getId());
                award.ifPresent(awardReceived::setAward);
                awardReceived.setPlayer(player);
            });
        }

        // If we are cascading/persisting teams played for, we need to fetch the teams from the database
        if(player.getTeamsPlayedFor() != null){
            for(int i = 0; i < player.getTeamsPlayedFor().size(); i++){
                Team team = player.getTeamsPlayedFor().get(i);
                Optional<Team> teamFromDb = teamRepository.findById(team.getId());

                int finalI = i;
                teamFromDb.ifPresent(fetchedTeam -> player.getTeamsPlayedFor().set(finalI, fetchedTeam));
            }
        }

        return playerRepository.save(player);
    }

    public Player patch(Player existingPlayer, Map<String, Object> updates){
        for (Map.Entry<String, Object> entry : updates.entrySet()) {
            switch (entry.getKey()) {
                case "firstName":
                    existingPlayer.setFirstName((String) entry.getValue());
                    break;
                case "lastName":
                    existingPlayer.setLastName((String) entry.getValue());
                    break;
                case "position":
                    existingPlayer.setPosition((Position) entry.getValue());
                    break;
                case "team":
                    existingPlayer.setTeam((Team) entry.getValue());
                    break;
                case "franchiseTagged":
                    existingPlayer.setFranchiseTagged((Boolean) entry.getValue());
                    break;
            }
        }

        return playerRepository.save(existingPlayer);
    }

    public void deleteById(Long id){
        playerRepository.deleteById(id);
    }
}

Major Topics

  • Utilizing services for database transactions.
  • Handling of cascading relationships
  • Usage of transient data transfer objects (DTOs)

Controllers

Controllers are responsible for handling REST requests and responding to them. Methods should be relatively simple and should invoke service calls rather than implementing any business logic themselves.

Player Controller
@RestController
@RequestMapping("/player")
@Profile("api")
public class PlayerController{

    private static final Logger log = LoggerFactory.getLogger(PlayerController.class);

    private final PlayerService playerService;

    @Autowired
    public PlayerController(PlayerService playerService) {
        this.playerService = playerService;
    }

    @GetMapping
    public ResponseEntity<List<Player>> findAll() {
        return ResponseEntity.ok(playerService.findAll());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Player> findById(@PathVariable("id") Long id) {
        log.debug("Finding player by id: {}", id);
        return playerService.findById(id)
                .map(ResponseEntity::ok)
                .orElseThrow(() -> new ResponseStatusException(
                        HttpStatus.NOT_FOUND,
                        "Player not found with id: " + id
                ));
    }

    @PostMapping
    public ResponseEntity<Player> create(@RequestBody Player player) {
        return ResponseEntity.ok(playerService.save(player));
    }

    @PutMapping("/{id}")
    public ResponseEntity<Player> update(@PathVariable("id") Long id, @RequestBody Player player) {
        player.setId(id);
        return ResponseEntity.ok(playerService.save(player));
    }

    @PatchMapping("/{id}")
    public ResponseEntity<Player> patchPlayer(Long id, Map<String, Object> updates) {
        Player existingPlayer = playerService.findById(id)
                .orElseThrow(() -> new ResponseStatusException(
                        HttpStatus.NOT_FOUND,
                        "Player not found with id: " + id
                ));

        return ResponseEntity.ok(playerService.patch(existingPlayer, updates));

    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable("id") Long id) {
        playerService.deleteById(id);
        return ResponseEntity.ok(null);
    }

}

Major Topics

  • Implementing HTTP methods (GET, POST, PUT, PATCH, DELETE)
  • Error handling

Scheduled

If your application has scheduled tasks, I like having a dedicated package for classes with scheduled methods. Similar to controllers, all the logic should reside in services and the methods here should be very simple.

Major Topics

  • Enabling
  • Scheduling options

Config

This is where we would see json serialization configuation, scheduling configuration, and most importantly, security configuration, where we apply custom request filters and parse JWTs for example.

Major Topics

  • Json Configuration
  • Customization from application.properties
  • Security
  • Exception Handling
  • Data Initialization

Application Properties

Major Topics

  • application.properties

    • Important Properties to define
    • Profiles
  • build.gradle

  • settings.gradle
Property Files
# Profiles 
spring.profiles.active=local,api

# Initialize Sql Statements 
spring.datasource.initialization-mode=NEVER
#spring.datasource.data=data.sql

# Scheduling Config (default setting: will be overridden by scheduling profile) 
scheduling.enabled=false

# Web Application Type (default setting: will be overridden by api profile) 
spring.main.web-application-type=none
server.port=-1
1
2
3
4
5
6
7
8
# API Details 
server.address=127.0.0.1
server.port=8080
server.servlet.context-path=/api
spring.main.web-application-type=servlet

# Used By Executor Service Config 
thread.pool.size=5 
# Database Configuration 
spring.datasource.url= jdbc:mysql://localhost:3306/tutorials?useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username=tutorial_master
spring.datasource.password=tutorial

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect

# SQL Printing
spring.jpa.show-sql=true # (2)
spring.jpa.properties.hibernate.format_sql=true # (3)
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE # (4)

# Max database connections 
spring.datasource.maxActive=10

# Allow for batch inserts 
spring.jpa.properties.hibernate.jdbc.batch_size=500
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

# DDL: none, validate, update, create, create-drop 
spring.jpa.hibernate.ddl-auto=create-drop

# Logging Levels 
logging.level.com.thomaswildetech=TRACE

# Logging Pattern
logging.pattern.console=%clr(%d %-7level %-60logger{36} Line %-5L %-40M - %msg%n)

# Allows for color logging
spring.output.ansi.enabled=always
scheduling.enabled=true
spring.task.scheduling.pool.size=5

Sample Code

Entity/Repository/Service/Controller Code
Player.java
    @Entity // 
    @Table(name = "lu_player", // 
        indexes = {
            @Index(columnList = "firstName, lastName", name = "idx_lu_player_first_name"),
            @Index(columnList = "lastName, firstName", name = "idx_lu_player_last_name"),
            @Index(columnList = "position_id", name = "idx_lu_player_position"),
            @Index(columnList = "team_id", name = "idx_lu_player_team"),
        }
    )
    public class Player {

        @Id // 
        @GeneratedValue(strategy = GenerationType.IDENTITY) // 
        private Long id; // 

        @Column(length = 50) // 
        private String firstName;

        @Column(length = 50)
        private String lastName;

        @ManyToOne(fetch = FetchType.EAGER) // 
        @JoinColumn(name = "position_id", referencedColumnName = "id", foreignKey = @ForeignKey(name = "FK_PLAYER_TO_POSITION")) // 
        private Position position;

        @ManyToOne
        @JoinColumn(name = "team_id", referencedColumnName = "id", foreignKey = @ForeignKey(name = "FK_PLAYER_TO_TEAM"))
        @JsonIgnoreProperties("roster")
        private Team team;

        @OneToMany(mappedBy = "player"
                , cascade = {CascadeType.PERSIST, CascadeType.MERGE}
        )
        // Ensure at the database level that if a player is deleted, all of their awards are also deleted
        @OnDelete(action = OnDeleteAction.CASCADE)
        @JsonIgnoreProperties("player")
        private List<AwardReceived> receivedAwards;

        @ManyToMany(
                cascade = {CascadeType.PERSIST, CascadeType.MERGE}
        )
        @JoinTable(
                name = "j_player_team",
                joinColumns = @JoinColumn(name = "player_id", referencedColumnName = "id", foreignKey = @ForeignKey(name = "FK_PLAYER_TEAM_TO_PLAYER")),
                inverseJoinColumns = @JoinColumn(name = "team_id", referencedColumnName = "id", foreignKey = @ForeignKey(name = "FK_PLAYER_TEAM_TO_TEAM"))
        )
        // Spring knows to delete the join table records when a player is deleted
        // However, without an OnDelete Cascade, you cannot delete a player from the database because you will get a FK constrain violation
        @OnDelete(action = OnDeleteAction.CASCADE)
        private List<Team> teamsPlayedFor;

        private Boolean franchiseTagged;

        @Transient
        @JsonIgnore
        private String someTempVariable;
        // Getters and Setters
    }
@Repository
public interface PlayerRepository extends JpaRepository<Player, Long> {

    @Override
    @EntityGraph(attributePaths = {"team", "position"})  // Can't fetch mutliple "bags" (lists) with EntityGraph
    List<Player> findAll();

    @Override
    @EntityGraph(attributePaths = {"team", "position", "teamsPlayedFor", "receivedAwards", "receivedAwards.award"}) // Can fetch multiple "bags" when returning only 1 result
    Optional<Player> findById(Long id);
}
@Service
public class PlayerService {

    private final PlayerRepository playerRepository;

    private final AwardRepository awardRepository;

    private final TeamRepository teamRepository;

    @Autowired
    public PlayerService(PlayerRepository playerRepository, AwardRepository awardRepository, TeamRepository teamRepository) {
        this.playerRepository = playerRepository;
        this.awardRepository = awardRepository;
        this.teamRepository = teamRepository;
    }

    public List<Player> findAll() {
        return playerRepository.findAll();
    }

    public Optional<Player> findById(Long id){
        return playerRepository.findById(id);
    }

    public Player save(Player player) {

        // If we are cascading/persisting new received awards, we need to fetch the award from the database
        if(player.getReceivedAwards() != null){
            player.getReceivedAwards().forEach(awardReceived -> {
                Optional<Award> award = awardRepository.findById(awardReceived.getAward().getId());
                award.ifPresent(awardReceived::setAward);
                awardReceived.setPlayer(player);
            });
        }

        // If we are cascading/persisting teams played for, we need to fetch the teams from the database
        if(player.getTeamsPlayedFor() != null){
            for(int i = 0; i < player.getTeamsPlayedFor().size(); i++){
                Team team = player.getTeamsPlayedFor().get(i);
                Optional<Team> teamFromDb = teamRepository.findById(team.getId());

                int finalI = i;
                teamFromDb.ifPresent(fetchedTeam -> player.getTeamsPlayedFor().set(finalI, fetchedTeam));
            }
        }

        return playerRepository.save(player);
    }

    public Player patch(Player existingPlayer, Map<String, Object> updates){
        for (Map.Entry<String, Object> entry : updates.entrySet()) {
            switch (entry.getKey()) {
                case "firstName":
                    existingPlayer.setFirstName((String) entry.getValue());
                    break;
                case "lastName":
                    existingPlayer.setLastName((String) entry.getValue());
                    break;
                case "position":
                    existingPlayer.setPosition((Position) entry.getValue());
                    break;
                case "team":
                    existingPlayer.setTeam((Team) entry.getValue());
                    break;
                case "franchiseTagged":
                    existingPlayer.setFranchiseTagged((Boolean) entry.getValue());
                    break;
            }
        }

        return playerRepository.save(existingPlayer);
    }

    public void deleteById(Long id){
        playerRepository.deleteById(id);
    }
}
@RestController
@RequestMapping("/player")
@Profile("api")
public class PlayerController{

    private static final Logger log = LoggerFactory.getLogger(PlayerController.class);

    private final PlayerService playerService;

    @Autowired
    public PlayerController(PlayerService playerService) {
        this.playerService = playerService;
    }

    @GetMapping
    public ResponseEntity<List<Player>> findAll() {
        return ResponseEntity.ok(playerService.findAll());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Player> findById(@PathVariable("id") Long id) {
        log.debug("Finding player by id: {}", id);
        return playerService.findById(id)
                .map(ResponseEntity::ok)
                .orElseThrow(() -> new ResponseStatusException(
                        HttpStatus.NOT_FOUND,
                        "Player not found with id: " + id
                ));
    }

    @PostMapping
    public ResponseEntity<Player> create(@RequestBody Player player) {
        return ResponseEntity.ok(playerService.save(player));
    }

    @PutMapping("/{id}")
    public ResponseEntity<Player> update(@PathVariable("id") Long id, @RequestBody Player player) {
        player.setId(id);
        return ResponseEntity.ok(playerService.save(player));
    }

    @PatchMapping("/{id}")
    public ResponseEntity<Player> patchPlayer(Long id, Map<String, Object> updates) {
        Player existingPlayer = playerService.findById(id)
                .orElseThrow(() -> new ResponseStatusException(
                        HttpStatus.NOT_FOUND,
                        "Player not found with id: " + id
                ));

        return ResponseEntity.ok(playerService.patch(existingPlayer, updates));

    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable("id") Long id) {
        playerService.deleteById(id);
        return ResponseEntity.ok(null);
    }

}

Database Structure

Was this page helpful?

Comments