How I Built a 3-Tier Approval Engine with Spring Boot and Spring Security

java dev.to

A deep-dive into multi-level workflow logic, stateless JWT auth, service-layer authorization guards, and a full CI/CD pipeline — all in production.


Why I Built This

Most backend tutorials teach you CRUD. Register, login, get a list of items. That's fine — but it doesn't prepare you for the kind of systems real companies actually run.

Almost every organization has some version of an approval workflow: an employee submits a leave request, a manager reviews it, a department head gives final sign-off. When this lives in emails and spreadsheets, things get lost, audits become impossible, and privilege abuse goes undetected.

I wanted to build a system that solves this properly — with real state machine logic, enforced role boundaries, a full audit trail, and the kind of security architecture you'd find in a production backend. This is what I built, and here's exactly how it works.

Live Swagger UI: https://workflow-approval-system-zq0a.onrender.com/swagger-ui.html

GitHub: https://github.com/DeepsanBhandari/workflow-approval-system


The Core Problem: State Machines Are Harder Than CRUD

The fundamental challenge in an approval workflow isn't the endpoints — it's state integrity. A workflow request has states:

DRAFT → PENDING_MANAGER → PENDING_ADMIN → APPROVED
                       ↘               ↘
                     REJECTED        REJECTED
                       ↘
                  CHANGES_REQUESTED
Enter fullscreen mode Exit fullscreen mode

Every transition has rules:

  • Only an EMPLOYEE can submit a DRAFT
  • Only a MANAGER can act on PENDING_MANAGER
  • Only an ADMIN can act on PENDING_ADMIN
  • You cannot skip levels
  • You cannot act on a completed workflow

If you don't enforce these at the service layer, your API will have holes regardless of how good your controller-level checks are.


Architecture: Layered + DTO-First

The system follows strict layered architecture:

Controller Layer   →   receives HTTP requests, delegates immediately
Service Layer      →   owns ALL business logic and authorization guards  
Repository Layer   →   pure data access, no business logic
Enter fullscreen mode Exit fullscreen mode

This is not just clean code philosophy — it has a concrete security benefit. If you put authorization logic in controllers, it's easy to bypass with direct service calls in tests or internal integrations. Authorization at the service layer is always enforced, regardless of how the service is invoked.

Every API contract is typed through DTOs — WorkflowResponse, ApprovalStepResponse, ApiResponseVoid. No entity ever leaks to the outside world. This enforces zero data leakage across roles by design.


Security: JWT with a Custom Filter Chain

I implemented stateless JWT authentication using a custom Spring Security filter. Here's the architectural decision that matters:

Token validation happens at the perimeter. Business logic is fully decoupled from auth concerns.

The custom JwtAuthenticationFilter extends OncePerRequestFilter. It:

  1. Extracts the Authorization: Bearer <token> header
  2. Validates the token signature using the secret key
  3. Loads UserDetails and sets SecurityContextHolder
  4. Passes to the next filter in the chain
// Conceptual flow inside the filter
String token = extractToken(request);
if (token != null && jwtService.isTokenValid(token)) {
    UserDetails userDetails = userDetailsService.loadUserByUsername(
        jwtService.extractUsername(token)
    );
    UsernamePasswordAuthenticationToken authToken =
        new UsernamePasswordAuthenticationToken(
            userDetails, null, userDetails.getAuthorities()
        );
    SecurityContextHolder.getContext().setAuthentication(authToken);
}
filterChain.doFilter(request, response);
Enter fullscreen mode Exit fullscreen mode

Why stateless? Because stateless JWT means no session storage on the server. The system can scale horizontally without sticky sessions. Each request is self-contained — the token carries the identity claim.

Passwords are hashed using BCryptPasswordEncoder. Never stored in plaintext, never logged.


The Service-Layer Authorization Guard (The Key Insight)

This is the most important architectural decision in the whole system.

Here's the problem with controller-level role checks:

// ❌ Fragile — only enforced at the HTTP boundary
@PreAuthorize("hasRole('MANAGER')")
@PostMapping("/{id}/approve")
public ResponseEntity<?> approve(@PathVariable Long id) {
    workflowService.approve(id); // No guard inside
}
Enter fullscreen mode Exit fullscreen mode

If workflowService.approve() is ever called internally — from a scheduler, a test, another service — the role check is bypassed.

Here's the approach I used instead:

// ✅ Enforced regardless of caller
public WorkflowResponse approve(Long workflowId, String approverUsername) {
    Workflow workflow = workflowRepository.findById(workflowId)
        .orElseThrow(() -> new WorkflowNotFoundException(workflowId));

    User approver = userRepository.findByUsername(approverUsername)
        .orElseThrow(() -> new UserNotFoundException(approverUsername));

    // Guard: is this approver allowed to act on this workflow state?
    validateApproverCanAct(workflow.getStatus(), approver.getRole());

    // Guard: is the workflow in an actionable state at all?
    validateWorkflowIsActionable(workflow.getStatus());

    // ... proceed with state transition
}
Enter fullscreen mode Exit fullscreen mode

validateApproverCanAct throws UnauthorizedActionException if a Manager tries to act on PENDING_ADMIN or vice versa. This isn't a Spring Security concern — it's business rule authorization, and it lives exactly where it should: in the service.


The State Transition Engine

The approval steps are sequential, not parallel. Each transition is explicit:

private void validateAndTransition(Workflow workflow, ApprovalAction action, User actor) {
    WorkflowStatus current = workflow.getStatus();

    switch (current) {
        case PENDING_MANAGER:
            requireRole(actor, Role.MANAGER);
            if (action == ApprovalAction.APPROVE) {
                workflow.setStatus(WorkflowStatus.PENDING_ADMIN);
            } else if (action == ApprovalAction.REJECT) {
                workflow.setStatus(WorkflowStatus.REJECTED);
            } else {
                workflow.setStatus(WorkflowStatus.CHANGES_REQUESTED);
            }
            break;
        case PENDING_ADMIN:
            requireRole(actor, Role.ADMIN);
            if (action == ApprovalAction.APPROVE) {
                workflow.setStatus(WorkflowStatus.APPROVED);
            } else {
                workflow.setStatus(WorkflowStatus.REJECTED);
            }
            break;
        default:
            throw new InvalidWorkflowStateException(current);
    }
}
Enter fullscreen mode Exit fullscreen mode

No state can be skipped. No actor can act outside their level. Invalid transitions throw typed exceptions caught by the global exception handler.


Audit Trail: Every Action is Recorded

Every approval, rejection, or change request is recorded as an ApprovalStep entity:

ApprovalStep {
    workflowId,
    actorUsername,
    actorRole,
    action (APPROVE / REJECT / REQUEST_CHANGES),
    comment,
    timestamp
}
Enter fullscreen mode Exit fullscreen mode

GET /api/workflows/{id}/history returns the full ordered history. This is non-negotiable for any real approval system — you need to know who did what and when.


Testing: 80%+ Coverage with JUnit 5 + Mockito

I focused test coverage on the parts that matter most — the service layer where business rules live.

Example: testing that a Manager cannot approve an ADMIN-level step:

@Test
void managerCannotApproveAdminLevelStep() {
    Workflow workflow = createWorkflowWithStatus(WorkflowStatus.PENDING_ADMIN);
    User manager = createUserWithRole(Role.MANAGER);

    assertThrows(UnauthorizedActionException.class, () ->
        workflowService.approve(workflow.getId(), manager.getUsername())
    );
}
Enter fullscreen mode Exit fullscreen mode

Testing the happy path is easy. Testing the edge cases — what happens if a Manager tries to jump the queue, what happens if someone tries to act on an already-approved workflow — is where the real coverage comes from.


CI/CD Pipeline: Zero Manual Steps

The GitHub Actions pipeline runs on every push to master:

jobs:
  build-and-deploy:
    steps:
      - name: Run Tests
        run: mvn test          # Fail fast — catch regressions before merge

      - name: Build Docker Image
        run: docker build -t workflow-approval-system .

      - name: Push to Registry
        run: docker push ...

      - name: Deploy to Render
        # Render auto-deploys from the registry on image push
Enter fullscreen mode Exit fullscreen mode

If tests fail, the pipeline stops. Nothing broken ever reaches production. The Dockerfile uses a multi-stage build — compile in one stage, run in a slim JRE image — keeping the final image lean.


What I'd Add Next

Honest reflection on what's missing from a true enterprise system:

  1. Notifications — Email/webhook trigger when a workflow reaches your level
  2. Kafka for audit events — Publish approval events to a message broker instead of synchronous DB writes
  3. Pagination on history endpoints — Fine for now, but breaks under large audit logs
  4. Refresh token rotation — Current JWT implementation uses single tokens; production needs refresh + revocation

Key Takeaways

If you're building your own approval system or similar state-machine backend:

  • Put authorization guards in the service layer, not just controllers
  • Model state transitions explicitly — don't rely on if/else chains scattered across the codebase
  • Every action that mutates state should be audited
  • 80%+ test coverage on business logic is achievable and worth it — not for the metric, but because the tests force you to think about edge cases

The full system is live, fully documented via Swagger, and available on GitHub. If you have questions about any implementation detail, drop a comment — happy to go deeper on any section.


Deepsan Bhandari is a Java/Spring Boot backend engineer based in Nepal, currently completing a Bachelor of Civil Engineering at IOE Purwanchal Campus while building production backend systems. Open to remote internships and freelance backend work.

GitHub: github.com/DeepsanBhandari | LinkedIn: linkedin.com/in/deepsan-bhandari

Source: dev.to

arrow_back Back to Tutorials