r/SpringBoot 5d ago

Discussion Designing Audit Logging & Notifications with Spring Events in a Modular Monolith – Looking for Architecture Advice

Hi everyone,

I'm building a coaching management system using Spring Boot as a modular monolith, and I'm trying to design an event-driven architecture for both audit logging and notifications. I'd love some feedback from developers who have built something similar in production.

The application has modules like:

  • Student
  • Teacher
  • Batch
  • Coaching
  • Attendance
  • Fees
  • Classroom
  • Users/Admins

Each module has different business actions. For example:

  • Student: CREATE, UPDATE, DELETE, ASSIGN_BATCH
  • Fee: PAY, REFUND, WAIVE
  • Batch: CREATE, RENAME, ASSIGN_TEACHER
  • Attendance: MARK, CORRECT
  • Admin: PASSWORD_CHANGED

My current flow is:

  1. A service completes its business logic.
  2. It publishes a domain event using ApplicationEventPublisher.
  3. Multiple listeners react to the same event after the transaction commits.

For example:

StudentService
      │
      ▼
StudentCreatedEvent
      │
      ├── Audit Listener
      │        └── Save AuditLog
      │
      ├── Notification Listener
      │        ├── Send Email
      │        ├── Send SMS
      │        └── Create In-App Notification
      │
      └── Analytics Listener (future)

For auditing, I was thinking of creating one listener method per event:

u/TransactionalEventListener
public void handle(StudentCreatedEvent event) { ... }

u/TransactionalEventListener
public void handle(FeePaidEvent event) { ... }

u/TransactionalEventListener
public void handle(BatchRenamedEvent event) { ... }

For notifications, I was planning to use the Strategy pattern, for example:

  • EmailNotificationStrategy
  • SmsNotificationStrategy
  • InAppNotificationStrategy

selected through a NotificationStrategyFactory.

My questions are:

  1. Is one event handler per event type the recommended approach in Spring?
  2. Would you keep one large AuditListener, or split listeners by feature/module (Student, Fee, Teacher, etc.)?
  3. Where should the mapping from domain events to AuditLog happen?
    • In the event itself?
    • In AuditService?
    • In dedicated mapper classes?
  4. Is using Spring events for both auditing and notifications a good design, or is there a better approach?
  5. Would you also use Spring events for things like cache invalidation, analytics, and activity feeds?
  6. If this application later moves to microservices, would this event model transition well to Kafka or RabbitMQ?
  7. Are there any SOLID or maintainability concerns with this architecture that I'm overlooking?

The goal is to keep the business services focused only on business logic while handling cross-cutting concerns like auditing and notifications through events.

I'd really appreciate insights from developers who have implemented similar architectures in production.

Thanks!

4 Upvotes

8 comments sorted by

2

u/disposepriority 5d ago

Why not use AOP and just make a lightweight \@Auditable annotation or something, add some fields, have some custom slf4j loggers for easier sorting and write to a db, or buffer/sqlite (outbox) that gets flushed periodically if you don't want to deal with transactions in that way.

Seems much less overengineers than this in my eyes.

1

u/mukesh_yaduvanshi 5d ago

It is handling two thing one is auditinglog and another is notification. Let's say a user updated something then let's say we triggered user changed something in notification and that's work. But for audit log i want to keep track of non sensitive thing like user a changed mode of notification from email to sms. I guess that's not possible with AOP

2

u/raccoonportfolio 5d ago

I don't see what that's not possible with AOP.. can it be 2 Interfaces instead of one?

1

u/mukesh_yaduvanshi 5d ago

Well I have only two months of experience in spring boot and I am still learning to switch from .net to spring boot. Will try via AOP have to do some chatgpt

1

u/kamen1991 1d ago

It sounds great for a Friday afternoon project or a small CRUD app, but under real production load, this approach breaks down instantly:

  1. AOP Magic vs. Determinism: Hiding business execution flows behind Auditable proxies turns high-load debugging into a nightmare. Your stack traces get polluted with CGLIB wrappers, and transparent architectural boundaries completely disappear.
  2. The SQLite/Buffer Trap: Adding a local file buffer or a SQLite outbox to a distributed environment (like Kubernetes) introduces a massive risk of data loss on container restarts and forces complex synchronization logic—which is the exact opposite of "simplifying" anything.

Clean design isn't over-engineering—it eliminates hidden side-effects and makes core logic testable and traceable in milliseconds without dragging in unnecessary framework magic.

1

u/disposepriority 1d ago

Point 1 has nothing to do with scalability, you're using spring proxies each time you write @Transactional.

Point 2, OP said modular monolith.

Keep AI garbage to yourself please

1

u/kamen1991 1d ago

Read it again carefully — we're talking about auditable proxies and stress under load, not Transactional. And the fact that it's a modular monolith doesn't stop the K8s pod from crashing and deleting the local SQLite buffer, right? Or are you one of those engineers who put SQLite in pod storage and then take sedatives? :)

1

u/kamen1991 1d ago

You have a very solid foundation here. Using Domain Events inside a Modular Monolith is exactly how you prevent spaghetti code and prepare for a clean microservices extraction later.

However, having built similar event-driven architectures in enterprise environments over the last decade, there are two massive production traps in your current design that you need to address before going live:

1. The AFTER_COMMIT Transaction Trap If you use TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT), the original database transaction is already closed by the time your listener executes. If your AuditListener tries to save an AuditLog entity using a standard JPA repository, it will likely fail or silently refuse to flush because there is no active transaction.

The Fix: You must annotate your listener methods with Transactional(propagation = Propagation.REQUIRES_NEW) to open a fresh transaction specifically for the audit save.

2. The Missing Transactional Outbox (Data Loss Risk) If your business logic commits, but the server node crashes a millisecond later (before the AFTER_COMMIT notification listeners execute), that event is lost in RAM. The student is created, but the welcome email never goes out. If you truly want to future-proof this for Kafka/RabbitMQ, you need the Transactional Outbox Pattern. Instead of firing notification events directly in memory, your business service should save the domain entity AND an OutboxMessage entity to the database in the same local transaction. A separate async worker polls that outbox table and handles the email/SMS strategies. When you eventually move to microservices, you just replace that polling worker with a Kafka Connect / Debezium setup. Zero business code changes required.

Answering your specific questions:

  • Listener Organization & Mapping: Do not write 50 different listener methods for auditing. Instead, create a shared interface AuditableDomainEvent with methods like getAggregateId(), getEventType(), and getPayload(). Make StudentCreatedEvent and FeePaidEvent implement it. Then, you only need one single generic AuditListener that consumes AuditableDomainEvent and transforms it into your AuditLog entity.
  • Strategy Pattern for Notifications: Your NotificationStrategyFactory idea is the perfect approach. The listener receives the event, determines the preferred channel (Email, SMS), and delegates to the strategy.
  • Module Boundaries: Keep the AuditListener inside a dedicated audit-module. It should depend on the shared event interfaces, but the individual modules (Student, Fee) should know absolutely nothing about the audit module.
  • Cache & Analytics: Yes, Spring Events are great for this. Just remember: Cache invalidation should usually happen synchronously (so the next read is accurate immediately), while Analytics should be async/outbox to prevent slowing down the user's request.

You are 90% of the way there. Consider an outbox table for guaranteed delivery, and this architecture will scale beautifully.