We’ve all been there. You need to return some complex, related data from a method, and creating a new class feels like overkill. So, you reach for the most flexible tool in the box: a Map. Then the data gets more nested, and you end up with the dreaded Map<Key1, Map<Key2, Value>>.
While this approach works, it plants the seeds for future confusion, bugs, and even security vulnerabilities. It creates code that is hard to read, awkward to use, and a nightmare to maintain.

1. The Problem: Code That Asks Too Many Questions
Let’s look at a classic example: a system for checking user permissions. A common “quick” solution is to load all permissions into a nested map.
private Map<String, Map<String, String>> loadAllPermissions() {
// Skipping actual data access code for brevity.
// Logic to load ALL permissions from a database.
return Map.of(
"user1", Map.of(
"document1", "WRITE",
"document2", "READ"
)
);
}
private void checkAccess() {
final Map<String, Map<String, String>> allPermissions = loadAllPermissions();
final String currentUser = "user1";
final String resource = "document1";
// How do we safely check for WRITE access?
final Map<String, String> userPerms = allPermissions.get(currentUser);
if (userPerms != null) {
final String permissionLevel = userPerms.get(resource); // Could be null
// Is "write" or "WRITE" the correct value? Is there an ADMIN level?
if ("WRITE".equals(permissionLevel) || "ADMIN".equals(permissionLevel)) {
System.out.println("Access granted.");
} else {
System.out.println("Access denied, insufficient permissions.");
}
} else {
System.out.println("Access denied, no permissions found for user.");
}
}This data structure is a minefield of potential issues:
- Brittleness: Two separate
nullchecks are required just to safely access a permission. A forgotten check leads directly to aNullPointerException. - Ambiguity: The permissions are represented by raw
Strings. This invites bugs from typos (“WRTIE” instead of “WRITE”) and inconsistencies (“admin” vs “ADMIN”). - Scattered Logic: The business rule—that an “ADMIN” also has “WRITE” permission—is scattered wherever this check is performed. If a new “OWNER” level is added, you have to hunt down and update every single one of these checks.
2. The Solution: Model Your Domain with Objects
Instead of forcing our complex domain into a generic data structure, let’s create objects that represent it perfectly. This allows us to centralize logic and create a clear, safe API.
First, let’s eliminate the “magic strings” with a dedicated Enum. By giving each permission level an explicit priority field, we create a robust system that isn’t dependent on the declaration order.
public enum PermissionLevel {
NONE(0),
READ(1),
WRITE(2),
ADMIN(3);
private final int priority;
PermissionLevel(final int priority) {
this.priority = priority;
}
public boolean isAtLeast(final PermissionLevel permissionLevel) {
return permissionLevel != null && this.priority >= permissionLevel.priority;
}
}Note: Using an explicit priority field makes our code more maintainable and less prone to bugs. Now, another developer can add new permission levels or re-sort the existing ones alphabetically without breaking our security checks. This is a hallmark of defensive, professional coding.
Next, we’ll model a single permission grant with its own record. This is our pure, logical domain model.
/**
* Represents a single grant of a specific permission level on a resource.
*/
public record Permission(String resourceId, PermissionLevel level) {}Finally, our UserPermissions record provides a clean API while using an optimized internal structure for high performance. It cleverly separates the “what” from the “how.”
/**
* An immutable representation of a user's permissions, optimized for fast lookups.
*/
public record UserPermissions(String userId, Map<String, PermissionLevel> permissions) {
/**
* A convenience constructor that accepts a pure Set of Permission objects and
* transforms it into an optimized internal Map for O(1) lookups.
*/
public UserPermissions(final String userId, final Set<Permission> userPermissions) {
this(
userId,
userPermissions.stream()
.collect(
Collectors.toMap(
Permission::resourceId,
Permission::level
)
)
);
}
/**
* Checks if the user can perform an action. This lookup is O(1) because
* it operates on the internal Map.
*/
public boolean can(final PermissionLevel requiredLevel, final String resourceId) {
final PermissionLevel userLevel = permissions.getOrDefault(resourceId, PermissionLevel.NONE);
return userLevel.isAtLeast(requiredLevel);
}
}Now, look how clean, clear, and performant our access check becomes.
private UserPermissions loadPermissionsFor(final String userId) {
// Skipping actual data access code for brevity.
// Logic to load ALL permissions for ONLY this user.
final Set<Permission> userPermissions = Set.of(
new Permission("document1", PermissionLevel.WRITE),
new Permission("document2", PermissionLevel.READ)
);
return new UserPermissions(userId, userPermissions);
}
private void checkAccess() {
final UserPermissions currentUserPermissions = loadPermissionsFor("user1");
// The API is now clear, safe, and speaks the language of our domain.
if (currentUserPermissions.can(PermissionLevel.WRITE, "document1")) {
System.out.println("Access granted.");
} else {
System.out.println("Access denied.");
}
}3. The Payoff: Clarity, Safety, and Craftsmanship
By investing a few extra minutes to create simple, focused records, we’ve transformed our code from a source of bugs into a model of clarity.
- Readability: The code now speaks for itself.
currentUserPermissions.can(PermissionLevel.WRITE, ...)is infinitely clearer than a series of null checks and string comparisons. - Safety: Our domain objects handle all the internal logic, preventing
NullPointerExceptions and ensuring business rules are applied consistently. - Maintainability: The business logic is in one place. If we add a new permission level, we only have to update the
PermissionLevelenum. The rest of the application will automatically respect the new rules. - Performance: By using a
Mapas a private implementation detail, we get O(1) lookups while still exposing a pure, logical API with theSet<Permission>constructor.
What’s fascinating is that if you were to serialize our UserPermissions object to JSON, the output could be identical to the one from the messy map. For example, an API endpoint might return:
{
"user-47b": {
"billing-dashboard": "ADMIN",
"user-profile-9c2": "WRITE",
"document-101": "READ"
},
"editor-c81": {
"document-101": "WRITE",
"project-gamma-report": "READ"
}
}
This reveals a critical lesson: external representation is not a measure of internal quality. One solution provides a functional result but leaves a trail of technical debt. The other provides the exact same result while creating a safe, maintainable, and expressive foundation for the future.
It’s not about writing more code; it’s about writing the right code. The initial cost of creating these small domain objects is repaid every single time another developer reads, uses, or needs to safely modify it.
