Breaking Free from the Outbox Pattern: A Better Way to Handle Transactional Audit Logs
We’ve all been there. You’re building a system that needs rock-solid audit logging, and you want to ensure that every database change is properly tracked. The traditional outbox pattern seems like the obvious choice, but something feels off. Your database procedures are getting cluttered with logging logic, and your clean domain model is starting to leak into your data layer.
The Traditional Approach: Why It’s Not Always Ideal
If you’re like most developers, you’ve probably implemented the outbox pattern before. It looks something like this:
- Create a database table for outbound messages
- Insert your audit log message into this table in the same transaction as your main data changes
- Have a separate process poll this table and forward messages to your actual logging system
This works, but it comes with some real drawbacks:
Your database procedures now need to know about your audit logging formatYour domain events are tightly coupled to your database schemaYou need additional infrastructure to reliably process the outbox table The audit logs reflect the database state, not your business entities
A Fresh Perspective: Application-Level Consistency
What if I told you there’s a cleaner way? One that keeps your concerns separated but still maintains transactional consistency? Let’s look at a real-world example.
Here’s the core idea: instead of putting our audit logs in the database, we can use our database transaction as a coordination point. We won’t commit the transaction until we’re sure our audit log has been sent.
Here’s what this looks like in practice in a simple example, in reality it would be a bit more abstracted (into services, repositories, etc):
public async Task<IActionResult> ProcessTransaction([FromBody] TransactionRequest request) { await (_connection as NpgsqlConnection)!.OpenAsync(); using var transaction = _connection.BeginTransaction(IsolationLevel.Serializable); try { // Your database changes CallPostgresql(request, transaction);
// Send your audit log SendKafkaMessage(request);
// Only commit if both operations succeed transaction.Commit(); return Ok(new { Message = "Transaction processed successfully" }); } catch { transaction.Rollback(); throw; } finally { if (_connection.State == ConnectionState.Open) { _connection.Close(); } } }
Why This Approach is Better
Clean Separation of Concerns: Your database layer stays focused on data persistence, while your application layer handles audit logging.
Rich Domain Events: Your audit logs can contain rich information about your business entities, not just raw database changes.
Simpler Infrastructure: No need for outbox tables or message polling systems. You could keep your outbox, and make it a separate SQL call with this pattern too, but why delay the sending of the message?
Still Transactionally Consistent: If the audit log fails to send, the database transaction rolls back.
But What About Performance?
I know what you’re thinking: “Won’t holding the database transaction open while we send a message add latency?”
Yes, it will. But let’s be honest — how often is your audit logging system your performance bottleneck? In most cases, the simplicity and maintainability benefits far outweigh the small performance cost.
If you do need blazing-fast performance, you can still fall back to the outbox pattern. But usually sending a message is as fast if not faster than writing to a db table.
Making It Production-Ready
To make this pattern work in production, consider these tips:
Set Appropriate Timeouts: Make sure your database transaction timeout is longer than your messaging timeout.
Handle Retries: Implement proper retry logic for your message sending and database calls.
Monitor Transaction Times: Keep an eye on transaction durations to ensure they stay within acceptable limits.
Conclusion
Sometimes, the conventional wisdom isn’t the best solution for your specific case. While the outbox pattern has its place, don’t be afraid to consider alternatives that might better fit your needs.
This approach of using database transactions to coordinate application-level consistency might seem unconventional, but it can lead to cleaner, more maintainable code while still providing the guarantees you need.
Remember: patterns are guidelines, not rules. The best solution is often the one that makes your code clearer and your system simpler to understand and maintain.
What patterns have you challenged lately? I’d love to hear about your experiences in the comments below.