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:
- Scalable Architecture: Microservices-ready design with clear separation of concerns
- Performance Optimization: Caching, indexing, async processing, and efficient queries
- Security: JWT authentication, role-based access, data encryption
- Modern Stack: Spring Boot, Next.js, MongoDB working together seamlessly
- 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.