Planned architecture : This page describes the intended three-tier architecture pattern for OrgStack. The current codebase provides foundational infrastructure (BaseEntity, JpaConfig, BackendApplication) but does not yet implement controllers, services, or repositories.
Overview
OrgStack is designed to follow a clean layered architecture that separates concerns and makes the codebase maintainable, testable, and scalable. The planned architecture will enforce clear boundaries between layers, with each layer having specific responsibilities.
Architectural layers
OrgStack implements a three-tier architecture pattern:
┌─────────────────────────────────────────┐
│ Controller Layer │
│ (REST endpoints, request validation) │
└─────────────────┬───────────────────────┘
│ DTOs
↓
┌─────────────────────────────────────────┐
│ Service Layer │
│ (Business logic, transactions) │
└─────────────────┬───────────────────────┘
│ Entities
↓
┌─────────────────────────────────────────┐
│ Repository Layer │
│ (Data access, JPA queries) │
└─────────────────┬───────────────────────┘
│
↓
PostgreSQL Database
Each layer communicates only with adjacent layers. Controllers never access repositories directly, ensuring proper separation of concerns.
Controller layer
Controllers handle HTTP requests and responses. They are responsible for:
Accepting HTTP requests
Validating request parameters and body
Converting DTOs to domain models
Delegating business logic to services
Converting domain models to response DTOs
Handling HTTP status codes and headers
@ RestController
@ RequestMapping ( "/api/projects" )
@ RequiredArgsConstructor
public class ProjectController {
private final ProjectService projectService ;
@ GetMapping
@ PreAuthorize ( "hasRole('USER')" )
public ResponseEntity < List < ProjectResponse >> listProjects () {
List < Project > projects = projectService . findAllInCurrentOrganization ();
return ResponseEntity . ok (
projects . stream ()
. map (ProjectResponse :: fromEntity)
. toList ()
);
}
@ PostMapping
@ PreAuthorize ( "hasRole('ADMIN')" )
public ResponseEntity < ProjectResponse > createProject (
@ Valid @ RequestBody CreateProjectRequest request ) {
Project project = projectService . createProject (request);
return ResponseEntity
. status ( HttpStatus . CREATED )
. body ( ProjectResponse . fromEntity (project));
}
}
Controller responsibilities
Request validation Use @Valid and Bean Validation annotations to validate input
HTTP semantics Return appropriate status codes (200, 201, 400, 404, etc.)
Error handling Catch service exceptions and convert to HTTP responses
Documentation Use OpenAPI annotations for API documentation
Controllers should be thin. Never put business logic in controllers. Delegate to services immediately.
Service layer
Services contain business logic and orchestrate operations across repositories. They are responsible for:
Implementing business rules
Managing transactions
Enforcing authorization rules
Coordinating multiple repository calls
Validating business constraints
Emitting domain events
@ Service
@ RequiredArgsConstructor
@ Transactional
public class ProjectService {
private final ProjectRepository projectRepository ;
private final UserRepository userRepository ;
private final SecurityContext securityContext ;
public Project createProject ( CreateProjectRequest request ) {
// Get current user and organization from security context
UUID orgId = securityContext . getCurrentOrganizationId ();
UUID userId = securityContext . getCurrentUserId ();
// Validate business rules
if ( projectRepository . countByOrganizationId (orgId) >= getMaxProjects (orgId)) {
throw new ProjectLimitExceededException ();
}
// Create entity
Project project = new Project ();
project . setName ( request . getName ());
project . setOrganizationId (orgId);
project . setOwnerId (userId);
// Persist
return projectRepository . save (project);
}
@ Transactional ( readOnly = true )
public List < Project > findAllInCurrentOrganization () {
UUID orgId = securityContext . getCurrentOrganizationId ();
return projectRepository . findByOrganizationId (orgId);
}
}
Service characteristics
Transactional
Annotate services with @Transactional to ensure database consistency. Use readOnly = true for queries.
Stateless
Services should not maintain state between method calls. They are Spring singletons.
Tenant-aware
Always filter data by the current organization. Never leak data across tenant boundaries.
Testable
Design services to be easily unit tested with mocked repositories.
Repository layer
Repositories abstract data access using Spring Data JPA. They are responsible for:
Defining query methods
Executing database operations
Managing entity lifecycle
Providing custom queries when needed
@ Repository
public interface ProjectRepository extends JpaRepository < Project , UUID > {
List < Project > findByOrganizationId ( UUID organizationId );
@ Query ( "SELECT p FROM Project p WHERE p.organizationId = :orgId AND p.status = :status" )
List < Project > findActiveProjects (
@ Param ( "orgId" ) UUID organizationId ,
@ Param ( "status" ) ProjectStatus status
);
long countByOrganizationId ( UUID organizationId );
boolean existsByOrganizationIdAndName ( UUID organizationId , String name );
}
Spring Data JPA automatically implements these interfaces at runtime. You only need to define the method signatures.
Repository best practices
Use method name conventions
Spring Data JPA derives queries from method names:
findByOrganizationId → WHERE organization_id = ?
findByNameContaining → WHERE name LIKE %?%
existsByEmail → SELECT EXISTS(WHERE email = ?)
Custom queries with @Query
For complex queries, use JPQL or native SQL: @ Query ( value = "SELECT * FROM projects WHERE created_at > :since" , nativeQuery = true )
List < Project > findRecentProjects (@ Param ( "since" ) Instant since);
Include organization ID in all query methods to enforce multi-tenancy: List < Project > findByOrganizationIdAndStatus ( UUID orgId, Status status);
Base entity design
All entities inherit from BaseEntity, which provides common fields:
package com.orgstack.common;
import java.time.Instant;
import java.util.UUID;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
@ MappedSuperclass
@ EntityListeners ( AuditingEntityListener . class )
@ Getter
public class BaseEntity {
@ Column ( nullable = false , updatable = false )
private UUID id ;
@ CreatedDate
@ Column ( nullable = false , updatable = false )
private Instant createdAt ;
@ LastModifiedDate
@ Column ( nullable = false )
private Instant updatedAt ;
protected BaseEntity () {
this . id = UUID . randomUUID ();
}
}
Entity features
UUID primary keys : Prevents ID enumeration and supports distributed systems
Automatic timestamps : JPA auditing populates createdAt and updatedAt
Immutable IDs : The updatable = false constraint prevents accidental changes
Lombok integration : @Getter generates getters automatically
Enable JPA auditing by adding @EnableJpaAuditing to a configuration class.
JPA configuration
OrgStack enables JPA auditing for automatic timestamp management:
package com.orgstack.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@ Configuration
@ EnableJpaAuditing
public class JpaConfig {
}
This configuration allows @CreatedDate and @LastModifiedDate annotations in BaseEntity to work automatically.
Database configuration
OrgStack uses PostgreSQL with production-ready settings:
spring.application.name =backend
# Datasource
spring.datasource.url =jdbc:postgresql://localhost:5432/orgstack
spring.datasource.username =orgstack
spring.datasource.password =orgstack_dev_password
# JPA
spring.jpa.hibernate.ddl-auto =validate
spring.jpa.show-sql =false
spring.jpa.properties.hibernate.format_sql =false
spring.jpa.open-in-view =false
Configuration explained
ddl-auto=validate Validates schema against entities but doesn’t auto-generate tables. Use migrations instead.
show-sql=false Disables SQL logging in production for performance. Enable in development.
open-in-view=false Disables lazy loading outside transactions to prevent N+1 queries.
PostgreSQL Production-grade relational database with excellent JSON support.
Technology stack
OrgStack is built with modern, production-ready technologies:
< parent >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-parent </ artifactId >
< version > 4.0.3 </ version >
</ parent >
< properties >
< java.version > 25 </ java.version >
</ properties >
< dependencies >
<!-- Web framework -->
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-webmvc </ artifactId >
</ dependency >
<!-- Security -->
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-security </ artifactId >
</ dependency >
<!-- Data access -->
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-data-jpa </ artifactId >
</ dependency >
<!-- Database -->
< dependency >
< groupId > org.postgresql </ groupId >
< artifactId > postgresql </ artifactId >
< scope > runtime </ scope >
</ dependency >
<!-- Validation -->
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-validation </ artifactId >
</ dependency >
<!-- Observability -->
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-actuator </ artifactId >
</ dependency >
</ dependencies >
Application entry point
The main application class bootstraps Spring Boot:
package com.orgstack;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@ SpringBootApplication
public class BackendApplication {
public static void main ( String [] args ) {
SpringApplication . run ( BackendApplication . class , args);
}
}
@SpringBootApplication enables:
Component scanning
Auto-configuration
Configuration properties
Dependency injection
OrgStack uses constructor injection for all dependencies:
@ Service
@ RequiredArgsConstructor // Lombok generates constructor
public class UserService {
private final UserRepository userRepository ;
private final PasswordEncoder passwordEncoder ;
private final EmailService emailService ;
// Dependencies are final and injected via constructor
}
Constructor injection is preferred over field injection because it makes dependencies explicit and enables easier testing.
Error handling
Implement a global exception handler for consistent error responses:
@ RestControllerAdvice
public class GlobalExceptionHandler {
@ ExceptionHandler ( EntityNotFoundException . class )
public ResponseEntity < ErrorResponse > handleNotFound ( EntityNotFoundException ex ) {
return ResponseEntity
. status ( HttpStatus . NOT_FOUND )
. body ( new ErrorResponse ( ex . getMessage ()));
}
@ ExceptionHandler ( ForbiddenException . class )
public ResponseEntity < ErrorResponse > handleForbidden ( ForbiddenException ex ) {
return ResponseEntity
. status ( HttpStatus . FORBIDDEN )
. body ( new ErrorResponse ( ex . getMessage ()));
}
}
Testing strategy
Unit tests
Test services in isolation with mocked repositories: @ ExtendWith ( MockitoExtension . class )
class ProjectServiceTest {
@ Mock
private ProjectRepository projectRepository ;
@ InjectMocks
private ProjectService projectService ;
}
Integration tests
Test controllers with full Spring context: @ SpringBootTest
@ AutoConfigureMockMvc
class ProjectControllerTest {
@ Autowired
private MockMvc mockMvc ;
}
Repository tests
Test queries against a real database: @ DataJpaTest
class ProjectRepositoryTest {
@ Autowired
private ProjectRepository projectRepository ;
}
Best practices
Single responsibility Each class should have one reason to change. Keep controllers thin, services focused.
Dependency inversion Depend on interfaces, not concrete implementations. Use Spring’s auto-configuration.
Transaction boundaries Keep transactions short. Use @Transactional on service methods, not controllers.
Immutability Prefer immutable DTOs and value objects. Use records in Java 17+.
Next steps
Multi-tenancy Learn how tenant isolation flows through layers
Authentication Understand JWT-based security
Authorization Implement role-based access control