Jomari AbejoJomari Abejo alternate
Jomari Abejo

Full Stack Developer

Building a Scalable Payroll System with Spring Boot, Next.js, and MongoDB

Introduction

Payroll systems are mission-critical applications that require accuracy, security, and performance. In this comprehensive guide, we'll build a production-ready payroll system using modern technologies: Spring Boot for the backend, Next.js for the frontend, and MongoDB for data persistence.

System Architecture Overview

┌─────────────────────────────────────────────────────────────┐
                        Client Layer                          
  ┌────────────────────────────────────────────────────┐    
             Next.js Frontend (Port 3000)                 
     Server-Side Rendering                               
     React Components                                    
     State Management (Zustand/Redux)                    
  └────────────────────────────────────────────────────┘    
└─────────────────────────────────────────────────────────────┘
                             HTTPS/REST API
┌─────────────────────────────────────────────────────────────┐
                      API Gateway Layer                       
  ┌────────────────────────────────────────────────────┐    
           Spring Boot Backend (Port 8080)                
     RESTful APIs                                        
     JWT Authentication                                  
     Business Logic                                      
     Data Validation                                     
  └────────────────────────────────────────────────────┘    
└─────────────────────────────────────────────────────────────┘
                             MongoDB Driver
┌─────────────────────────────────────────────────────────────┐
                      Data Layer                              
  ┌────────────────────────────────────────────────────┐    
                MongoDB Database                           
    Collections:                                           
     employees                                            
     payroll                                              
     attendance                                           
     deductions                                           
     users (authentication)                               
  └────────────────────────────────────────────────────┘    
└─────────────────────────────────────────────────────────────┘

Database Schema Design

Employee Collection

{
  "_id": "ObjectId",
  "employeeId": "EMP001",
  "firstName": "John",
  "lastName": "Doe",
  "email": "john.doe@company.com",
  "department": "Engineering",
  "position": "Senior Developer",
  "hireDate": "2023-01-15",
  "salary": {
    "base": 75000,
    "currency": "USD",
    "payFrequency": "MONTHLY"
  },
  "bankDetails": {
    "accountNumber": "encrypted_string",
    "bankName": "XYZ Bank",
    "routingNumber": "encrypted_string"
  },
  "status": "ACTIVE",
  "createdAt": "ISODate",
  "updatedAt": "ISODate"
}

Payroll Collection

{
  "_id": "ObjectId",
  "employeeId": "EMP001",
  "payPeriod": {
    "startDate": "2024-10-01",
    "endDate": "2024-10-31"
  },
  "earnings": {
    "baseSalary": 6250,
    "overtime": 500,
    "bonus": 1000,
    "total": 7750
  },
  "deductions": {
    "tax": 1550,
    "insurance": 200,
    "retirement": 312.5,
    "total": 2062.5
  },
  "netPay": 5687.5,
  "status": "PROCESSED",
  "processedDate": "ISODate",
  "paymentDate": "2024-11-05"
}

Backend Implementation (Spring Boot)

1. Project Setup

pom.xml dependencies:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-mongodb</artifactId>
    </dependency>
    <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>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
}</dependencies>

2. Domain Models

Employee.java:

@Document(collection = "employees")
@Data
@Builder
public class Employee {
    @Id
    private String id;
    
    @Indexed(unique = true)
    private String employeeId;
    
    @NotBlank
    private String firstName;
    
    @NotBlank
    private String lastName;
    
    @Email
    @Indexed(unique = true)
    private String email;
    
    private String department;
    private String position;
    
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate hireDate;
    
    @NotNull
    private Salary salary;
    
    private BankDetails bankDetails;
    
    @Enumerated(EnumType.STRING)
    private EmployeeStatus status;
    
    @CreatedDate
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
}

@Data
@Builder
class Salary {
    private BigDecimal base;
    private String currency;
    private PayFrequency payFrequency;
}

Payroll.java:

@Document(collection = "payroll")
@Data
@Builder
public class Payroll {
    @Id
    private String id;
    
    @Indexed
    private String employeeId;
    
    private PayPeriod payPeriod;
    private Earnings earnings;
    private Deductions deductions;
    
    private BigDecimal netPay;
    
    @Enumerated(EnumType.STRING)
    private PayrollStatus status;
    
    private LocalDateTime processedDate;
    private LocalDate paymentDate;
}

@Data
@Builder
class Earnings {
    private BigDecimal baseSalary;
    private BigDecimal overtime;
    private BigDecimal bonus;
    private BigDecimal total;
}

@Data
@Builder
class Deductions {
    private BigDecimal tax;
    private BigDecimal insurance;
    private BigDecimal retirement;
    private BigDecimal total;
}

3. Repository Layer

@Repository
public interface EmployeeRepository extends MongoRepository<Employee, String> {
    Optional<Employee> findByEmployeeId(String employeeId);
    Optional<Employee> findByEmail(String email);
    List<Employee> findByDepartment(String department);
    List<Employee> findByStatus(EmployeeStatus status);
}

@Repository
public interface PayrollRepository extends MongoRepository<Payroll, String> {
    List<Payroll> findByEmployeeId(String employeeId);
    
    List<Payroll> findByPayPeriodStartDateBetween(
        LocalDate start, 
        LocalDate end
    );
    
    Optional<Payroll> findByEmployeeIdAndPayPeriod(
        String employeeId, 
        PayPeriod payPeriod
    );
    
    @Query("{ 'status': ?0, 'paymentDate': { $lte: ?1 } }")
    List<Payroll> findPendingPayments(PayrollStatus status, LocalDate date);
}

4. Service Layer with Business Logic

@Service
@Transactional
@Slf4j
public class PayrollService {
    
    private final PayrollRepository payrollRepository;
    private final EmployeeRepository employeeRepository;
    private final TaxCalculationService taxCalculationService;
    
    public PayrollDTO processPayroll(String employeeId, PayPeriod period) {
        Employee employee = employeeRepository.findByEmployeeId(employeeId)
            .orElseThrow(() -> new EmployeeNotFoundException(employeeId));
        
        // Check for duplicate processing
        Optional<Payroll> existing = payrollRepository
            .findByEmployeeIdAndPayPeriod(employeeId, period);
        
        if (existing.isPresent()) {
            throw new DuplicatePayrollException(
                "Payroll already processed for this period"
            );
        }
        
        // Calculate earnings
        Earnings earnings = calculateEarnings(employee, period);
        
        // Calculate deductions
        Deductions deductions = calculateDeductions(employee, earnings);
        
        // Calculate net pay
        BigDecimal netPay = earnings.getTotal()
            .subtract(deductions.getTotal());
        
        // Create payroll record
        Payroll payroll = Payroll.builder()
            .employeeId(employeeId)
            .payPeriod(period)
            .earnings(earnings)
            .deductions(deductions)
            .netPay(netPay)
            .status(PayrollStatus.PROCESSED)
            .processedDate(LocalDateTime.now())
            .paymentDate(calculatePaymentDate(period))
            .build();
        
        Payroll saved = payrollRepository.save(payroll);
        
        log.info("Processed payroll for employee: {}, net pay: {}", 
                 employeeId, netPay);
        
        return mapToDTO(saved);
    }
    
    private Earnings calculateEarnings(Employee employee, PayPeriod period) {
        BigDecimal baseSalary = calculateBaseSalary(
            employee.getSalary().getBase(),
            employee.getSalary().getPayFrequency()
        );
        
        BigDecimal overtime = calculateOvertime(employee, period);
        BigDecimal bonus = getBonus(employee, period);
        
        BigDecimal total = baseSalary.add(overtime).add(bonus);
        
        return Earnings.builder()
            .baseSalary(baseSalary)
            .overtime(overtime)
            .bonus(bonus)
            .total(total)
            .build();
    }
    
    private Deductions calculateDeductions(Employee employee, Earnings earnings) {
        BigDecimal grossPay = earnings.getTotal();
        
        BigDecimal tax = taxCalculationService
            .calculateTax(grossPay, employee);
        
        BigDecimal insurance = calculateInsurance(employee);
        BigDecimal retirement = grossPay.multiply(new BigDecimal("0.05")); // 5%
        
        BigDecimal total = tax.add(insurance).add(retirement);
        
        return Deductions.builder()
            .tax(tax)
            .insurance(insurance)
            .retirement(retirement)
            .total(total)
            .build();
    }
    
    @Async
    public void processBatchPayroll(List<String> employeeIds, PayPeriod period) {
        employeeIds.parallelStream()
            .forEach(empId -> {
                try {
                    processPayroll(empId, period);
                } catch (Exception e) {
                    log.error("Failed to process payroll for {}: {}", 
                             empId, e.getMessage());
                }
            });
    }
}

5. REST Controllers

@RestController
@RequestMapping("/api/v1/payroll")
@Validated
@Slf4j
public class PayrollController {
    
    private final PayrollService payrollService;
    
    @PostMapping("/process")
    @PreAuthorize("hasRole('PAYROLL_ADMIN')")
    public ResponseEntity<PayrollDTO> processPayroll(
        @Valid @RequestBody ProcessPayrollRequest request
    ) {
        PayrollDTO result = payrollService.processPayroll(
            request.getEmployeeId(),
            request.getPayPeriod()
        );
        return ResponseEntity.ok(result);
    }
    
    @PostMapping("/batch")
    @PreAuthorize("hasRole('PAYROLL_ADMIN')")
    public ResponseEntity<BatchProcessResponse> processBatchPayroll(
        @Valid @RequestBody BatchProcessRequest request
    ) {
        payrollService.processBatchPayroll(
            request.getEmployeeIds(),
            request.getPayPeriod()
        );
        return ResponseEntity.accepted().build();
    }
    
    @GetMapping("/employee/{employeeId}")
    public ResponseEntity<List<PayrollDTO>> getEmployeePayroll(
        @PathVariable String employeeId,
        @RequestParam(required = false) Integer year
    ) {
        List<PayrollDTO> payrolls = payrollService
            .getPayrollHistory(employeeId, year);
        return ResponseEntity.ok(payrolls);
    }
    
    @GetMapping("/report")
    @PreAuthorize("hasRole('PAYROLL_ADMIN')")
    public ResponseEntity<PayrollReportDTO> generateReport(
        @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
        @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate
    ) {
        PayrollReportDTO report = payrollService
            .generateReport(startDate, endDate);
        return ResponseEntity.ok(report);
    }
}

@RestController
@RequestMapping("/api/v1/employees")
public class EmployeeController {
    
    private final EmployeeService employeeService;
    
    @PostMapping
    @PreAuthorize("hasRole('HR_ADMIN')")
    public ResponseEntity<EmployeeDTO> createEmployee(
        @Valid @RequestBody CreateEmployeeRequest request
    ) {
        EmployeeDTO employee = employeeService.createEmployee(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(employee);
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<EmployeeDTO> getEmployee(@PathVariable String id) {
        EmployeeDTO employee = employeeService.getEmployee(id);
        return ResponseEntity.ok(employee);
    }
    
    @PutMapping("/{id}")
    @PreAuthorize("hasRole('HR_ADMIN')")
    public ResponseEntity<EmployeeDTO> updateEmployee(
        @PathVariable String id,
        @Valid @RequestBody UpdateEmployeeRequest request
    ) {
        EmployeeDTO updated = employeeService.updateEmployee(id, request);
        return ResponseEntity.ok(updated);
    }
    
    @GetMapping
    public ResponseEntity<Page<EmployeeDTO>> listEmployees(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(required = false) String department
    ) {
        Pageable pageable = PageRequest.of(page, size);
        Page<EmployeeDTO> employees = employeeService
            .listEmployees(department, pageable);
        return ResponseEntity.ok(employees);
    }
}

Frontend Implementation (Next.js)

1. Project Structure

nextjs-payroll/
├── src/
   ├── app/
      ├── layout.tsx
      ├── page.tsx
      ├── employees/
         ├── page.tsx
         └── [id]/
             └── page.tsx
      ├── payroll/
         ├── page.tsx
         └── process/
             └── page.tsx
      └── reports/
          └── page.tsx
   ├── components/
      ├── ui/
      ├── employees/
      ├── payroll/
      └── layout/
   ├── lib/
      ├── api/
      ├── hooks/
      └── utils/
   └── types/
└── public/

2. API Client Setup

// lib/api/client.ts
import axios from 'axios';

const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1',
  headers: {
    'Content-Type': 'application/json',
  },
});

// Request interceptor for JWT token
apiClient.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('accessToken');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor for error handling
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // Handle token refresh or logout
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default apiClient;

3. API Service Layer

// lib/api/payroll.service.ts
import apiClient from './client';
import type { Payroll, PayPeriod, PayrollReport } from '@/types';

export const payrollService = {
  async processPayroll(employeeId: string, payPeriod: PayPeriod) {
    const response = await apiClient.post<Payroll>('/payroll/process', {
      employeeId,
      payPeriod,
    });
    return response.data;
  },

  async processBatch(employeeIds: string[], payPeriod: PayPeriod) {
    const response = await apiClient.post('/payroll/batch', {
      employeeIds,
      payPeriod,
    });
    return response.data;
  },

  async getEmployeePayroll(employeeId: string, year?: number) {
    const response = await apiClient.get<Payroll[]>(
      `/payroll/employee/${employeeId}`,
      { params: { year } }
    );
    return response.data;
  },

  async generateReport(startDate: string, endDate: string) {
    const response = await apiClient.get<PayrollReport>('/payroll/report', {
      params: { startDate, endDate },
    });
    return response.data;
  },
};

// lib/api/employee.service.ts
export const employeeService = {
  async getEmployees(page = 0, size = 20, department?: string) {
    const response = await apiClient.get('/employees', {
      params: { page, size, department },
    });
    return response.data;
  },

  async getEmployee(id: string) {
    const response = await apiClient.get(`/employees/${id}`);
    return response.data;
  },

  async createEmployee(data: CreateEmployeeRequest) {
    const response = await apiClient.post('/employees', data);
    return response.data;
  },

  async updateEmployee(id: string, data: UpdateEmployeeRequest) {
    const response = await apiClient.put(`/employees/${id}`, data);
    return response.data;
  },
};

4. React Components

// components/payroll/ProcessPayrollForm.tsx
'use client';

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { payrollService } from '@/lib/api/payroll.service';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';

interface ProcessPayrollFormProps {
  employeeId: string;
  onSuccess?: () => void;
}

export default function ProcessPayrollForm({ 
  employeeId, 
  onSuccess 
}: ProcessPayrollFormProps) {
  const [isProcessing, setIsProcessing] = useState(false);
  
  const { register, handleSubmit, formState: { errors } } = useForm({
    defaultValues: {
      startDate: '',
      endDate: '',
    },
  });

  const onSubmit = async (data: any) => {
    setIsProcessing(true);
    try {
      await payrollService.processPayroll(employeeId, {
        startDate: data.startDate,
        endDate: data.endDate,
      });
      
      toast({
        title: 'Success',
        description: 'Payroll processed successfully',
      });
      
      onSuccess?.();
    } catch (error: any) {
      toast({
        title: 'Error',
        description: error.response?.data?.message || 'Failed to process payroll',
        variant: 'destructive',
      });
    } finally {
      setIsProcessing(false);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium mb-1">
          Start Date
        </label>
        <input
          type="date"
          {...register('startDate', { required: 'Start date is required' })}
          className="w-full px-3 py-2 border rounded-md"
        />
        {errors.startDate && (
          <p className="text-red-500 text-sm mt-1">
            {errors.startDate.message}
          </p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium mb-1">
          End Date
        </label>
        <input
          type="date"
          {...register('endDate', { required: 'End date is required' })}
          className="w-full px-3 py-2 border rounded-md"
        />
        {errors.endDate && (
          <p className="text-red-500 text-sm mt-1">
            {errors.endDate.message}
          </p>
        )}
      </div>

      <Button
        type="submit"
        disabled={isProcessing}
        className="w-full"
      >
        {isProcessing ? 'Processing...' : 'Process Payroll'}
      </Button>
    </form>
  );
}
// components/payroll/PayrollHistory.tsx
'use client';

import { useEffect, useState } from 'react';
import { payrollService } from '@/lib/api/payroll.service';
import type { Payroll } from '@/types';

interface PayrollHistoryProps {
  employeeId: string;
}

export default function PayrollHistory({ employeeId }: PayrollHistoryProps) {
  const [payrolls, setPayrolls] = useState<Payroll[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadPayrollHistory();
  }, [employeeId]);

  const loadPayrollHistory = async () => {
    try {
      const data = await payrollService.getEmployeePayroll(employeeId);
      setPayrolls(data);
    } catch (error) {
      console.error('Failed to load payroll history:', error);
    } finally {
      setLoading(false);
    }
  };

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <div className="space-y-4">
      <h2 className="text-2xl font-bold">Payroll History</h2>
      
      <div className="overflow-x-auto">
        <table className="min-w-full divide-y divide-gray-200">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Pay Period
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Gross Pay
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Deductions
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Net Pay
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Status
              </th>
            </tr>
          </thead>
          <tbody className="bg-white divide-y divide-gray-200">
            {payrolls.map((payroll) => (
              <tr key={payroll.id}>
                <td className="px-6 py-4 whitespace-nowrap">
                  {payroll.payPeriod.startDate} - {payroll.payPeriod.endDate}
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                  ${payroll.earnings.total.toFixed(2)}
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                  ${payroll.deductions.total.toFixed(2)}
                </td>
                <td className="px-6 py-4 whitespace-nowrap font-semibold">
                  ${payroll.netPay.toFixed(2)}
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                  <span className={`px-2 py-1 rounded-full text-xs ${
                    payroll.status === 'PROCESSED' 
                      ? 'bg-green-100 text-green-800'
                      : 'bg-yellow-100 text-yellow-800'
                  }`}>
                    {payroll.status}
                  </span>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Optimization Strategies

1. Database Optimization

Indexing Strategy:

// MongoDB indexes
db.employees.createIndex({ "employeeId": 1 }, { unique: true });
db.employees.createIndex({ "email": 1 }, { unique: true });
db.employees.createIndex({ "department": 1, "status": 1 });

db.payroll.createIndex({ "employeeId": 1, "payPeriod.startDate": -1 });
db.payroll.createIndex({ "status": 1, "paymentDate": 1 });
db.payroll.createIndex({ 
  "employeeId": 1, 
  "payPeriod.startDate": 1 
}, { unique: true });

Aggregation Pipeline for Reports:

@Service
public class PayrollReportService {
    
    public PayrollSummary generateDepartmentSummary(
        String department, 
        LocalDate startDate, 
        LocalDate endDate
    ) {
        Aggregation aggregation = Aggregation.newAggregation(
            Aggregation.match(Criteria.where("payPeriod.startDate")
                .gte(startDate).lte(endDate)),
            Aggregation.lookup("employees", "employeeId", "employeeId", "employee"),
            Aggregation.unwind("employee"),
            Aggregation.match(Criteria.where("employee.department").is(department)),
            Aggregation.group("employee.department")
                .sum("netPay").as("totalPaid")
                .avg("netPay").as("avgPay")
                .count().as("employeeCount")
        );
        
        return mongoTemplate.aggregate(
            aggregation, 
            "payroll", 
            PayrollSummary.class
        ).getUniqueMappedResult();
    }
}

2. Backend Performance

Caching with Redis:

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration
            .defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer())
            );
        
        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}

@Service
public class EmployeeService {
    
    @Cacheable(value = "employees", key = "#id")
    public EmployeeDTO getEmployee(String id) {
        // Database call only on cache miss
        return employeeRepository.findById(id)
            .map(this::mapToDTO)
            .orElseThrow(() -> new EmployeeNotFoundException(id));
    }
    
    @CacheEvict(value = "employees", key = "#id")
    public EmployeeDTO updateEmployee(String id, UpdateEmployeeRequest request) {
        // Update and invalidate cache
        return updateEmployeeInternal(id, request);
    }
}

Async Processing:

@Configuration
@EnableAsync
public class AsyncConfig {
    
    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("payroll-async-");
        executor.initialize();
        return executor;
    }
}

3. Frontend Performance

Server-Side Rendering:

// app/employees/page.tsx
import { employeeService } from '@/lib/api/employee.service';

export default async function EmployeesPage() {
  // Data fetched on server
  const employees = await employeeService.getEmployees();
  
  return <EmployeeList initialData={employees} />;
}

Data Fetching with SWR:

'use client';

import useSWR from 'swr';
import { payrollService } from '@/lib/api/payroll.service';

export function usePayrollHistory(employeeId: string) {
  const { data, error, isLoading, mutate } = useSWR(
    `/payroll/employee/${employeeId}`,
    () => payrollService.getEmployeePayroll(employeeId),
    {
      revalidateOnFocus: false,
      revalidateOnReconnect: false,
      dedupingInterval: 60000, // 1 minute
    }
  );

  return {
    payrolls: data,
    isLoading,
    isError: error,
    refresh: mutate,
  };
}

Code Splitting and Lazy Loading:

// app/payroll/process/page.tsx
import dynamic from 'next/dynamic';

// Lazy load heavy components
const ProcessPayrollForm = dynamic(
  () => import('@/components/payroll/ProcessPayrollForm'),
  { 
    loading: () => <div>Loading form...</div>,
    ssr: false 
  }
);

const PayrollChart = dynamic(
  () => import('@/components/payroll/PayrollChart'),
  { ssr: false }
);

export default function ProcessPayrollPage() {
  return (
    <div>
      <ProcessPayrollForm />
      <PayrollChart />
    </div>
  );
}

4. API Optimization

Pagination and Filtering:

@RestController
@RequestMapping("/api/v1/payroll")
public class PayrollController {
    
    @GetMapping
    public ResponseEntity<Page<PayrollDTO>> getPayrolls(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(required = false) String employeeId,
        @RequestParam(required = false) PayrollStatus status,
        @RequestParam(required = false) 
        @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
        @RequestParam(required = false) 
        @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate
    ) {
        Pageable pageable = PageRequest.of(page, size, 
            Sort.by("payPeriod.startDate").descending());
        
        Page<PayrollDTO> result = payrollService.getPayrolls(
            employeeId, status, startDate, endDate, pageable
        );
        
        return ResponseEntity.ok(result);
    }
}

Response Compression:

@Configuration
public class CompressionConfig {
    
    @Bean
    public FilterRegistrationBean<GzipFilter> gzipFilter() {
        FilterRegistrationBean<GzipFilter> registration = 
            new FilterRegistrationBean<>();
        registration.setFilter(new GzipFilter());
        registration.addUrlPatterns("/api/*");
        return registration;
    }
}

DTO Projections:

// Only fetch required fields
public interface PayrollSummaryProjection {
    String getId();
    String getEmployeeId();
    PayPeriod getPayPeriod();
    BigDecimal getNetPay();
    PayrollStatus getStatus();
}

@Repository
public interface PayrollRepository extends MongoRepository<Payroll, String> {
    
    @Query(value = "{}", fields = "{ 'id': 1, 'employeeId': 1, " +
           "'payPeriod': 1, 'netPay': 1, 'status': 1 }")
    List<PayrollSummaryProjection> findAllSummaries(Pageable pageable);
}

Security Best Practices

1. Authentication & Authorization

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/api/v1/payroll/**")
                    .hasAnyRole("PAYROLL_ADMIN", "EMPLOYEE")
                .requestMatchers("/api/v1/employees/**")
                    .hasAnyRole("HR_ADMIN", "PAYROLL_ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, 
                UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}

@Service
public class PayrollSecurityService {
    
    public boolean canAccessPayroll(String employeeId) {
        Authentication auth = SecurityContextHolder
            .getContext()
            .getAuthentication();
        
        UserDetails user = (UserDetails) auth.getPrincipal();
        
        // Employees can only see their own payroll
        if (auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("ROLE_EMPLOYEE"))) {
            return user.getUsername().equals(employeeId);
        }
        
        // Admins can see all
        return auth.getAuthorities().stream()
            .anyMatch(a -> a.getAuthority().equals("ROLE_PAYROLL_ADMIN"));
    }
}

2. Data Encryption

@Component
public class EncryptionService {
    
    @Value("${encryption.secret-key}")
    private String secretKey;
    
    public String encrypt(String data) {
        try {
            SecretKeySpec key = new SecretKeySpec(
                secretKey.getBytes(), "AES"
            );
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.ENCRYPT_MODE, key);
            
            byte[] encrypted = cipher.doFinal(data.getBytes());
            return Base64.getEncoder().encodeToString(encrypted);
        } catch (Exception e) {
            throw new EncryptionException("Failed to encrypt data", e);
        }
    }
    
    public String decrypt(String encryptedData) {
        try {
            SecretKeySpec key = new SecretKeySpec(
                secretKey.getBytes(), "AES"
            );
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.DECRYPT_MODE, key);
            
            byte[] decrypted = cipher.doFinal(
                Base64.getDecoder().decode(encryptedData)
            );
            return new String(decrypted);
        } catch (Exception e) {
            throw new EncryptionException("Failed to decrypt data", e);
        }
    }
}

@Service
public class EmployeeService {
    
    private final EncryptionService encryptionService;
    
    public EmployeeDTO createEmployee(CreateEmployeeRequest request) {
        Employee employee = Employee.builder()
            .firstName(request.getFirstName())
            .lastName(request.getLastName())
            // Encrypt sensitive data
            .bankDetails(BankDetails.builder()
                .accountNumber(encryptionService.encrypt(
                    request.getBankDetails().getAccountNumber()
                ))
                .routingNumber(encryptionService.encrypt(
                    request.getBankDetails().getRoutingNumber()
                ))
                .bankName(request.getBankDetails().getBankName())
                .build())
            .build();
        
        Employee saved = employeeRepository.save(employee);
        return mapToDTO(saved);
    }
}

3. Input Validation

public class CreateEmployeeRequest {
    
    @NotBlank(message = "First name is required")
    @Size(min = 2, max = 50)
    private String firstName;
    
    @NotBlank(message = "Last name is required")
    @Size(min = 2, max = 50)
    private String lastName;
    
    @Email(message = "Invalid email format")
    @NotBlank
    private String email;
    
    @NotNull
    @Min(value = 0, message = "Salary must be positive")
    private BigDecimal baseSalary;
    
    @Pattern(regexp = "^[0-9]{9,12}$", 
             message = "Invalid account number")
    private String accountNumber;
}

@ControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
        MethodArgumentNotValidException ex
    ) {
        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(FieldError::getDefaultMessage)
            .collect(Collectors.toList());
        
        return ResponseEntity
            .badRequest()
            .body(new ErrorResponse("Validation failed", errors));
    }
}

Testing Strategy

1. Unit Tests

@ExtendWith(MockitoExtension.class)
class PayrollServiceTest {
    
    @Mock
    private PayrollRepository payrollRepository;
    
    @Mock
    private EmployeeRepository employeeRepository;
    
    @Mock
    private TaxCalculationService taxCalculationService;
    
    @InjectMocks
    private PayrollService payrollService;
    
    @Test
    void processPayroll_Success() {
        // Given
        String employeeId = "EMP001";
        Employee employee = createTestEmployee();
        PayPeriod period = new PayPeriod(
            LocalDate.of(2024, 10, 1),
            LocalDate.of(2024, 10, 31)
        );
        
        when(employeeRepository.findByEmployeeId(employeeId))
            .thenReturn(Optional.of(employee));
        when(taxCalculationService.calculateTax(any(), any()))
            .thenReturn(new BigDecimal("1550"));
        when(payrollRepository.save(any()))
            .thenAnswer(i -> i.getArgument(0));
        
        // When
        PayrollDTO result = payrollService.processPayroll(employeeId, period);
        
        // Then
        assertNotNull(result);
        assertEquals(employeeId, result.getEmployeeId());
        assertTrue(result.getNetPay().compareTo(BigDecimal.ZERO) > 0);
        
        verify(payrollRepository, times(1)).save(any(Payroll.class));
    }
    
    @Test
    void processPayroll_EmployeeNotFound() {
        // Given
        when(employeeRepository.findByEmployeeId(any()))
            .thenReturn(Optional.empty());
        
        // When & Then
        assertThrows(
            EmployeeNotFoundException.class,
            () -> payrollService.processPayroll("INVALID", new PayPeriod())
        );
    }
    
    @Test
    void processPayroll_DuplicateProcessing() {
        // Given
        Employee employee = createTestEmployee();
        PayPeriod period = new PayPeriod();
        
        when(employeeRepository.findByEmployeeId(any()))
            .thenReturn(Optional.of(employee));
        when(payrollRepository.findByEmployeeIdAndPayPeriod(any(), any()))
            .thenReturn(Optional.of(new Payroll()));
        
        // When & Then
        assertThrows(
            DuplicatePayrollException.class,
            () -> payrollService.processPayroll("EMP001", period)
        );
    }
}

2. Integration Tests

@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(locations = "classpath:application-test.properties")
class PayrollControllerIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @Autowired
    private EmployeeRepository employeeRepository;
    
    @Autowired
    private PayrollRepository payrollRepository;
    
    @BeforeEach
    void setUp() {
        payrollRepository.deleteAll();
        employeeRepository.deleteAll();
    }
    
    @Test
    @WithMockUser(roles = "PAYROLL_ADMIN")
    void processPayroll_Integration() throws Exception {
        // Given
        Employee employee = createAndSaveEmployee();
        
        ProcessPayrollRequest request = ProcessPayrollRequest.builder()
            .employeeId(employee.getEmployeeId())
            .payPeriod(new PayPeriod(
                LocalDate.of(2024, 10, 1),
                LocalDate.of(2024, 10, 31)
            ))
            .build();
        
        // When & Then
        mockMvc.perform(post("/api/v1/payroll/process")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.employeeId").value(employee.getEmployeeId()))
            .andExpect(jsonPath("$.netPay").exists())
            .andExpect(jsonPath("$.status").value("PROCESSED"));
        
        // Verify database
        List<Payroll> payrolls = payrollRepository
            .findByEmployeeId(employee.getEmployeeId());
        assertEquals(1, payrolls.size());
    }
}

3. Frontend Testing

// __tests__/components/ProcessPayrollForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProcessPayrollForm from '@/components/payroll/ProcessPayrollForm';
import { payrollService } from '@/lib/api/payroll.service';

jest.mock('@/lib/api/payroll.service');

describe('ProcessPayrollForm', () => {
  it('submits form with valid data', async () => {
    const mockOnSuccess = jest.fn();
    const mockProcessPayroll = jest.spyOn(payrollService, 'processPayroll')
      .mockResolvedValue({ id: '1', netPay: 5000 } as any);
    
    render(
      <ProcessPayrollForm 
        employeeId="EMP001" 
        onSuccess={mockOnSuccess} 
      />
    );
    
    const startDateInput = screen.getByLabelText(/start date/i);
    const endDateInput = screen.getByLabelText(/end date/i);
    const submitButton = screen.getByRole('button', { name: /process/i });
    
    await userEvent.type(startDateInput, '2024-10-01');
    await userEvent.type(endDateInput, '2024-10-31');
    await userEvent.click(submitButton);
    
    await waitFor(() => {
      expect(mockProcessPayroll).toHaveBeenCalledWith('EMP001', {
        startDate: '2024-10-01',
        endDate: '2024-10-31',
      });
      expect(mockOnSuccess).toHaveBeenCalled();
    });
  });
  
  it('shows error on validation failure', async () => {
    render(<ProcessPayrollForm employeeId="EMP001" />);
    
    const submitButton = screen.getByRole('button', { name: /process/i });
    await userEvent.click(submitButton);
    
    expect(await screen.findByText(/start date is required/i))
      .toBeInTheDocument();
  });
});

Deployment Configuration

1. Docker Setup

Backend Dockerfile:

FROM eclipse-temurin:17-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN ./mvnw clean package -DskipTests

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Frontend Dockerfile:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]

docker-compose.yml:

version: '3.8'

services:
  mongodb:
    image: mongo:7
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: password
    volumes:
      - mongodb_data:/data/db
    networks:
      - payroll-network

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    networks:
      - payroll-network

  backend:
    build: ./backend
    ports:
      - "8080:8080"
    environment:
      SPRING_DATA_MONGODB_URI: mongodb://admin:password@mongodb:27017/payroll?authSource=admin
      SPRING_REDIS_HOST: redis
      SPRING_REDIS_PORT: 6379
      JWT_SECRET: your-secret-key
    depends_on:
      - mongodb
      - redis
    networks:
      - payroll-network

  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:8080/api/v1
    depends_on:
      - backend
    networks:
      - payroll-network

volumes:
  mongodb_data:

networks:
  payroll-network:
    driver: bridge

2. Application Configuration

application.yml:

spring:
  application:
    name: payroll-service
  
  data:
    mongodb:
      uri: ${MONGODB_URI:mongodb://localhost:27017/payroll}
      auto-index-creation: true
  
  redis:
    host: ${REDIS_HOST:localhost}
    port: ${REDIS_PORT:6379}
  
  cache:
    type: redis
    redis:
      time-to-live: 600000

server:
  port: 8080
  compression:
    enabled: true
    mime-types: application/json,application/xml,text/html,text/xml,text/plain

logging:
  level:
    root: INFO
    com.payroll: DEBUG
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"

jwt:
  secret: ${JWT_SECRET}
  expiration: 86400000 # 24 hours

payroll:
  batch-size: 100
  async-pool-size: 10

Monitoring and Observability

1. Health Checks

@Component
public class PayrollHealthIndicator implements HealthIndicator {
    
    private final MongoTemplate mongoTemplate;
    
    @Override
    public Health health() {
        try {
            mongoTemplate.executeCommand("{ ping: 1 }");
            return Health.up()
                .withDetail("database", "MongoDB is responsive")
                .build();
        } catch (Exception e) {
            return Health.down()
                .withDetail("database", "MongoDB is not responsive")
                .withException(e)
                .build();
        }
    }
}

2. Metrics with Actuator

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
}</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
}</dependency>
@Service
public class PayrollMetricsService {
    
    private final MeterRegistry meterRegistry;
    
    public void recordPayrollProcessing(String employeeId, long duration) {
        Timer.builder("payroll.processing.time")
            .tag("employee", employeeId)
            .register(meterRegistry)
            .record(duration, TimeUnit.MILLISECONDS);
        
        meterRegistry.counter("payroll.processed.total").increment();
    }
    
    public void recordPayrollError(String errorType) {
        meterRegistry.counter("payroll.errors.total",
            "type", errorType).increment();
    }
}

Conclusion

This comprehensive payroll system demonstrates:

  1. Scalable Architecture: Microservices-ready design with clear separation of concerns
  2. Performance Optimization: Caching, indexing, async processing, and efficient queries
  3. Security: JWT authentication, role-based access, data encryption
  4. Modern Stack: Spring Boot, Next.js, MongoDB working together seamlessly
  5. Production-Ready: Testing, monitoring, Docker deployment, and error handling

The system can handle thousands of employees and can be extended with features like:

  • Direct deposit integration
  • Tax form generation (W-2, 1099)
  • Benefits management
  • Time tracking integration
  • Multi-currency support
  • Audit logging
  • Email notifications

This architecture provides a solid foundation for building enterprise-grade payroll applications.