Escaping mapping hell with Clojure
If you spend most of your time mapping data between services, maybe you're just using the wrong tool?
Open any enterprise Java or C# codebase and you'll find something disturbing: 70% of the code is just moving data from one shape to another. The same payment with ID 3 for $100 exists as PaymentRequest, PaymentDTO, Payment, PaymentCreatedEvent, and PaymentResponse. Five classes to represent one simple fact.
This isn't architecture. It's ceremony. And it's killing your productivity.
Every time you add a field, you update five classes, five mappers, and pray you didn't miss one. Your IDE helps with boilerplate, but that's like giving you a better shovel to dig a hole you shouldn't be digging in the first place.
Welcome to Mapping Hell
Here's what fetching a user looks like in a typical "clean architecture" C# application:
// Database Entity
public record UserEntity(int Id, string Name, string Email, DateTime CreatedAt);
// Domain Model (with "business logic encapsulation")
public record User(int Id, string Name, string Email, DateTime CreatedAt);
// DTO for API Response
public record UserDto(int Id, string Name, string Email);
// View Model for UI
public record UserViewModel(int Id, string DisplayName, string EmailAddress);
// Command for business operations
public record UpdateUserCommand(int Id, string Name, string Email);
// Event for event sourcing
public record UserUpdatedEvent(int Id, string Name, string Email, DateTime UpdatedAt);
// And now... the mappers
public class UserMapper
{
public User MapToDomain(UserEntity entity) =>
new User(entity.Id, entity.Name, entity.Email, entity.CreatedAt);
public UserDto MapToDto(User user) =>
new UserDto(user.Id, user.Name, user.Email);
public UserViewModel MapToViewModel(UserDto dto) =>
new UserViewModel(dto.Id, dto.Name, dto.Email);
public UpdateUserCommand MapToCommand(UserDto dto) =>
new UpdateUserCommand(dto.Id, dto.Name, dto.Email);
public UserUpdatedEvent MapToEvent(User user) =>
new UserUpdatedEvent(user.Id, user.Name, user.Email, DateTime.Now);
}
// The actual usage - John with ID 3 goes through 5 transformations
var entity = GetUserEntity(3); // From database
var domainUser = userMapper.MapToDomain(entity);
var dto = userMapper.MapToDto(domainUser);
var viewModel = userMapper.MapToViewModel(dto);
var command = userMapper.MapToCommand(dto);
var event = userMapper.MapToEvent(domainUser);That's 6 different representations and 5 mapper methods just to move John with ID 3 through your system. And this is a dead simple flat structure - just 4 fields.
Now imagine a real entity: A Customer with nested Addresses (array), PaymentMethods (array of objects with their own nested data), OrderHistory, Preferences (nested object), and AccountSettings. Each nested structure needs its own set of Entity/DTO/ViewModel classes. Each array needs mapping loops. Welcome to mapping hell - you'll need 20-30 classes and dozens of mapper methods for a single aggregate.
In a 100,000 line codebase, 70,000 lines are just shuffling the same data between different shaped boxes.
Why Does This Happen?
The road to mapping hell is paved with good intentions:
- "Separation of concerns" - The database shouldn't leak into the domain!
- "Loose coupling" - Each layer should be independent!
- "Clean architecture" - Boundaries everywhere!
- "SOLID principles" - Single responsibility for everything!
But what actually happens? You create tight coupling to the structure of your data. Change one field, update five classes. Add a property, modify five mappers. The ceremony becomes the architecture.
The Clojure Way: Data is Just Data
Here's the same user fetch in Clojure:
;; Your data - simple or nested, doesn't matter
(def user {:user/id 3
:user/name "John"
:user/email "john@example.com"
:user/addresses [{:address/type "home"
:address/street "123 Main"
:address/city "Boston"}
{:address/type "work"
:address/street "456 Corp"
:address/city "Cambridge"}]
:user/payment-methods [{:payment/type "card"
:payment/last4 "1234"
:payment/expires "12/25"}
{:payment/type "paypal"
:payment/email "john@paypal.com"}]})
;; Need it for the API? Just select what you need:
(select-keys user [:user/id :user/name :user/email])
;; Need MORE data for the API response? Just assoc it:
(assoc user
:api/version "2.0"
:api/timestamp (Instant/now)
:user/full-name (str (:user/name user) " Doe")
:user/address-count (count (:user/addresses user)))
;; Need nested data? It's already there:
(:user/addresses user) ; => the array, no mapping needed
;; Need to rename for external API? Namespace-aware transformation:
(set/rename-keys user {:user/name :display-name
:user/email :email-address})
;; Transform nested structures? Just walk the data:
(update user :user/addresses
(fn [addrs] (map #(select-keys % [:address/street :address/city]) addrs)))No explosion of classes for nested structures. No AddressEntity, AddressDto, AddressViewModel. No PaymentMethodEntity, PaymentMethodDto. The nested data is just... nested data. Transform it when needed, leave it alone when not.
- No classes to define structure
- No mappers to maintain
- No DTOs, ViewModels, or Entities
- No AutoMapper configurations
- No recompilation when adding a field
Just data. Plain, simple, transformable data.
But What About Type Safety?
"But without classes, how do I know what fields exist?" This is the wrong question. The right question is: "Why am I writing the same information 5 times just to know what fields exist?"
Clojure gives you multiple options for validation when you need it - Malli, clojure.spec, or Schema:
;; Define the shape once with Malli
(def User
[:map
[:user/id pos-int?]
[:user/name string?]
[:user/email [:re #".+@.+\..+"]]
[:user/addresses [:vector [:map
[:address/street string?]
[:address/city string?]]]]
[:user/payment-methods [:vector [:map
[:payment/type string?]
[:payment/last4 {:optional true} string?]
[:payment/email {:optional true} string?]]]]])
;; Validate at boundaries
(defn process-user [user]
(if (m/validate User user)
(do
;; Process the user - data flows through unchanged
...)
(throw (ex-info "Invalid user"
{:error (m/explain User user)}))))
;; Get nice error messages for free
(m/explain User {:user/id "not-a-number" :user/name "John"})
;; => {:errors [{:path [:user/id], :in [:user/id], :schema pos-int?, :value "not-a-number"}
;; {:path [:user/email], :in [:user/email], :schema [:re #".+@.+\..+"], :value nil}]}You get validation without the ceremony. Define the shape once, use it where needed, but don't rebuild your entire universe around it.
Microservices: Mapping Hell on Steroids
Think mapping is bad in a monolith? Wait until you see microservices. Now John with ID 3 doesn't just need 5 classes in one codebase - he needs 5 classes in every single service that touches user data.
- User Service: UserEntity, UserDto, UserApiResponse, UserEvent
- Payment Service: PaymentUser, PaymentUserDto, UserReference
- Notification Service: NotificationRecipient, RecipientDto, UserInfo
- Audit Service: AuditUser, UserAuditData, UserSnapshot
- Analytics Service: AnalyticsUser, UserMetrics, UserDimension
That's 20+ classes across 5 services to represent the same person. Each service maintains its own mappers, its own validation, its own version of "what is a user." Add a field? Update 5 services, 20 classes, and pray your API versioning strategy holds up.
In microservices, you haven't eliminated the mapping problem - you've distributed it and multiplied it by the number of services.
With Clojure? The same map flows through all services. Add transit or EDN for serialization and your data structure travels unchanged across service boundaries. No rebuilding, no mapping, no ceremony.
The Hidden Cost of Mapping
This isn't just about lines of code. The mapping tax compounds:
- Velocity killer: Adding a field touches 5-10 files minimum
- Bug factory: Logic gets duplicated across mappers, business rules leak into translation layers
- Cognitive overload: Developers spend more time thinking about translation than business logic
- Performance overhead: All those object allocations and copies add up
- Refactoring nightmare: Changing data flow means updating every boundary
Teams using Clojure report 3-5x productivity improvements. It's not because Clojure developers are smarter. It's because they're not writing mappers.
Making the Escape
You don't need to rewrite everything in Clojure tomorrow. But you should ask yourself:
- How much of your code is actually solving business problems vs. moving data around?
- How many bugs come from mapping inconsistencies?
- How long does it take to add a simple field throughout your system?
- Could you explain your 5-class user representation to a new developer in under 10 minutes?
If you're spending more time on data translation than domain logic, you're not doing architecture. You're doing bureaucracy. And there's a better way.
The Bottom Line
Every line of mapping code is a line that adds no business value. It's pure overhead. In Clojure, that overhead doesn't exist. Data is just data. Transform it when needed, validate it where it matters, but don't rebuild it at every boundary.
Stop mapping. Start shipping.