Skip to main content
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

1

Transactional

Annotate services with @Transactional to ensure database consistency. Use readOnly = true for queries.
2

Stateless

Services should not maintain state between method calls. They are Spring singletons.
3

Tenant-aware

Always filter data by the current organization. Never leak data across tenant boundaries.
4

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

Spring Data JPA derives queries from method names:
  • findByOrganizationIdWHERE organization_id = ?
  • findByNameContainingWHERE name LIKE %?%
  • existsByEmailSELECT EXISTS(WHERE email = ?)
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:
BaseEntity.java
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:
JpaConfig.java
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:
application.properties
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:
pom.xml
<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:
BackendApplication.java
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

1

Unit tests

Test services in isolation with mocked repositories:
@ExtendWith(MockitoExtension.class)
class ProjectServiceTest {
    @Mock
    private ProjectRepository projectRepository;
    
    @InjectMocks
    private ProjectService projectService;
}
2

Integration tests

Test controllers with full Spring context:
@SpringBootTest
@AutoConfigureMockMvc
class ProjectControllerTest {
    @Autowired
    private MockMvc mockMvc;
}
3

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

Build docs developers (and LLMs) love