The Problem with Direct Database Coupling
In many applications, business logic directly depends on database access, creating tight coupling:
// Traditional approach - Business logic depends on database
@Service
public class UserService {
@Autowired
private UserRepository userRepository; // Direct dependency on database
public void upgradeUserSubscription(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
if (user.canUpgrade()) {
user.setSubscriptionLevel(SubscriptionLevel.PREMIUM);
userRepository.save(user);
}
}
}
This approach has several problems:
- Business logic is tightly coupled to database implementation
- Difficult to test without a database
- Changes to persistence layer affect business logic
- Hard to switch to different storage solutions
The Solution: Dependency Inversion
Let's apply the Dependency Inversion Principle using ports and adapters (hexagonal architecture):
- First, define the business domain model:
public class UserDomain {
private final Long id;
private final String email;
private SubscriptionLevel subscriptionLevel;
// Constructor and methods...
public boolean canUpgrade() {
return subscriptionLevel == SubscriptionLevel.BASIC;
}
public void upgradeToPremium() {
if (!canUpgrade()) {
throw new BusinessException("User cannot be upgraded");
}
this.subscriptionLevel = SubscriptionLevel.PREMIUM;
}
}
- Create a port (interface) that business logic will use:
public interface UserPort {
UserDomain findUser(Long userId);
void saveUser(UserDomain user);
}
- Implement business logic that depends on the port:
@Service
public class UserService {
private final UserPort userPort;
public UserService(UserPort userPort) {
this.userPort = userPort;
}
public void upgradeUserSubscription(Long userId) {
UserDomain user = userPort.findUser(userId);
user.upgradeToPremium();
userPort.saveUser(user);
}
}
- Create a database adapter that implements the port:
@Component
public class UserDatabaseAdapter implements UserPort {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserDatabaseAdapter(UserRepository userRepository, UserMapper userMapper) {
this.userRepository = userRepository;
this.userMapper = userMapper;
}
@Override
public UserDomain findUser(Long userId) {
User userEntity = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
return userMapper.toDomain(userEntity);
}
@Override
public void saveUser(UserDomain userDomain) {
User userEntity = userMapper.toEntity(userDomain);
userRepository.save(userEntity);
}
}
- Create a mapper to convert between domain and entity:
@Component
public class UserMapper {
public UserDomain toDomain(User entity) {
return new UserDomain(
entity.getId(),
entity.getEmail(),
entity.getSubscriptionLevel()
);
}
public User toEntity(UserDomain domain) {
User entity = new User();
entity.setId(domain.getId());
entity.setEmail(domain.getEmail());
entity.setSubscriptionLevel(domain.getSubscriptionLevel());
return entity;
}
}
- Define the JPA entity:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
@Enumerated(EnumType.STRING)
private SubscriptionLevel subscriptionLevel;
// Getters and setters
}
Benefits of This Approach
-
Pure Business Logic:
- Business rules are isolated in the domain model
- No dependencies on infrastructure concerns
- Easy to test without mocking database calls
-
Flexibility:
- Easy to swap database implementations
- Can add caching or event publishing without touching business logic
- Can implement different adapters for different storage solutions
Testing:
public class UserServiceTest {
private UserService userService;
private UserPort userPort;
@BeforeEach
void setUp() {
userPort = new InMemoryUserPort(); // Test implementation
userService = new UserService(userPort);
}
@Test
void shouldUpgradeUserSubscription() {
// Given
UserDomain user = new UserDomain(1L, "test@test.com", SubscriptionLevel.BASIC);
((InMemoryUserPort) userPort).addUser(user);
// When
userService.upgradeUserSubscription(1L);
// Then
UserDomain updatedUser = userPort.findUser(1L);
assertEquals(SubscriptionLevel.PREMIUM, updatedUser.getSubscriptionLevel());
}
}
- Alternative Implementations:
@Component
@Profile("cache")
public class CachedUserAdapter implements UserPort {
private final UserPort databaseAdapter;
private final Cache cache;
@Override
public UserDomain findUser(Long userId) {
return cache.get(userId, () -> databaseAdapter.findUser(userId));
}
@Override
public void saveUser(UserDomain user) {
databaseAdapter.saveUser(user);
cache.put(user.getId(), user);
}
}
Configuration
@Configuration
public class UserConfig {
@Bean
public UserPort userPort(UserRepository repository, UserMapper mapper) {
return new UserDatabaseAdapter(repository, mapper);
}
@Bean
public UserService userService(UserPort userPort) {
return new UserService(userPort);
}
}
To conclude
By applying dependency inversion:
- Business logic becomes pure and focused
- Testing becomes easier
- The system becomes more flexible
- Different storage implementations can be swapped easily
- The code is more maintainable and follows SOLID principles
Remember:
- Domain models should be persistence-ignorant
- Business rules should be in the domain model
- Adapters handle infrastructure concerns
- Use interfaces (ports) to define boundaries
- Keep the domain model focused on business behavior
This architecture makes your code more resilient to change and easier to maintain over time.
Top comments (0)