DEV Community

Parzival
Parzival

Posted on

Decoupling Business Logic from Database Access with Dependency Inversion

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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):

  1. 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;
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Create a port (interface) that business logic will use:
public interface UserPort {
    UserDomain findUser(Long userId);
    void saveUser(UserDomain user);
}
Enter fullscreen mode Exit fullscreen mode
  1. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 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;
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 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
}
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach

  1. Pure Business Logic:

    • Business rules are isolated in the domain model
    • No dependencies on infrastructure concerns
    • Easy to test without mocking database calls
  2. Flexibility:

    • Easy to swap database implementations
    • Can add caching or event publishing without touching business logic
    • Can implement different adapters for different storage solutions
  3. 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());
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)