Designing a Modular Backend Service with DDD, Caching, Dynamic Proxies and Extensible Configuration
This article explains how to build a modular backend service in Java using domain‑driven design, configurable modules, dynamic proxies, cache adapters, lambda‑based query conditions, and Spring‑based conditional bean loading to enable flexible extensions without altering existing code.
This article introduces a modular backend service architecture that can be composed and extended according to project requirements. It covers project structure, RESTful API design, gateway routing, DDD domain‑driven design, and the challenges of expanding repositories and avoiding circular dependencies.
Project structure and modular construction ideas RESTful API design & management Gateway routing modular support & conditional configuration DDD domain‑driven design and business modularization (concepts, implementation, Schrodinger model, optimization) RPC modular design and distributed transactions Event modular design and lifecycle logging
Previously, a modular backend service was built for the Juejin platform with three modules: juejin-user , juejin-pin , and juejin-message . By adding startup modules, these can be combined or run as a single monolith.
Example 1: Combine juejin-user and juejin-message into one service using juejin-application-system , then expose juejin-pin separately via juejin-application-pin for precise scaling.
Example 2: Package all three modules into a single application with juejin-application-single for small‑scale projects.
To solve the problems of adding new repositories and circular dependencies, a DomainContext abstraction is introduced, backed by ApplicationContext to retrieve beans dynamically.
public interface DomainContext {
T get(Class
type);
}An implementation using Spring's ApplicationContext :
@AllArgsConstructor
public class ApplicationDomainContext implements DomainContext {
private ApplicationContext context;
@Override
public
T get(Class
type) {
return context.getBean(type);
}
}The SchrodingerClub model is refactored to use DomainContext for lazy loading, eliminating the need to pass many repositories.
public class SchrodingerClub extends ClubImpl implements Club {
protected DomainContext context;
protected SchrodingerClub(String id, DomainContext context) {
this.id = id;
this.context = context;
}
@Override
public String getName() {
if (this.name == null) {
load();
}
return this.name;
}
public void load() {
ClubRepository clubRepository = context.get(ClubRepository.class);
Club club = clubRepository.get(id);
if (club == null) {
throw new JuejinException("Club not found: " + id);
}
this.name = club.getName();
this.tag = club.getTag();
this.description = club.getDescription();
}
}Dynamic proxies are used to avoid manually overriding every method. The proxy intercepts calls, returns the id directly, and delegates other methods to the lazily loaded Club instance.
public class SchrodingerClub implements InvocationHandler {
protected String id;
protected Club club;
protected DomainContext context;
protected SchrodingerClub(String id, DomainContext context) {
this.id = id;
this.context = context;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("getId".equals(method.getName())) {
return id;
}
return method.invoke(getClub(), args);
}
protected Club getClub() {
if (this.club == null) {
ClubRepository clubRepository = context.get(ClubRepository.class);
Club club = clubRepository.get(id);
if (club == null) {
throw new JuejinException("Club not found: " + id);
}
this.club = club;
}
return this.club;
}
}A performance test compares native object creation with proxy creation, showing that proxies are slower but acceptable for many use cases.
Count
Native (ms avg)
Proxy (ms avg)
10k
2.33
20.66
100k
8.00
190.33
1M
35.00
1404.66
Cache abstraction is introduced to avoid repeated data queries. A generic Cache<T> interface defines set , get , and remove methods, while CacheProvider selects an appropriate adapter.
public interface Cache
{
void set(String id, T cache);
T get(String id);
void remove(String id);
}
public interface CacheProvider {
Cache
get(Object key);
}
public interface CacheAdapter {
boolean support(Object key);
Cache
adapt(Object key);
}An in‑memory adapter supports all keys, and a Redis adapter can be added for specific repositories such as CommentRepository .
@Component
@Order(0)
public class CommentCacheAdapter implements CacheAdapter {
@Autowired
private RedisTemplate
template;
@Override
public boolean support(Object key) {
return key instanceof CommentRepository;
}
@Override
public
Cache
adapt(Object key) {
return new RedisCache<>(template);
}
}Lambda‑based condition building replaces hard‑coded field names. By capturing a method reference (e.g., Pin::getId ) and extracting its SerializedLambda , the corresponding field name pinId can be derived automatically.
public
Conditions equal(LambdaFunction
lf, Object value) {
Method method = lf.getClass().getDeclaredMethod("writeReplace");
method.setAccessible(true);
SerializedLambda sl = (SerializedLambda) method.invoke(lf);
String key = handleKey(sl);
equal(key, value);
return this;
}For project‑specific extensions, Spring configuration classes with @Bean and @ConditionalOnMissingBean are used. By scanning only the extension package, new controllers, services, adapters, and repositories can be added without modifying existing code.
@Configuration
public class DomainPinConfiguration {
@Bean @ConditionalOnMissingBean
public PinController pinController() { return new PinController(); }
@Bean @ConditionalOnMissingBean
public PinService pinService() { return new PinService(); }
@Bean @ConditionalOnMissingBean
public PinFacadeAdapter pinFacadeAdapter() { return new PinFacadeAdapterImpl(); }
@Bean @ConditionalOnMissingBean
public PinInstantiator pinInstantiator() { return new PinInstantiatorImpl(); }
@Configuration @ConditionalOnMyBatisPlus
public static class MyBatisPlusConfiguration {
@Bean @ConditionalOnMissingBean
public PinIdGenerator pinIdGenerator() { return new MBPPinIdGenerator(); }
@Bean @ConditionalOnMissingBean
public PinRepository pinRepository() { return new MBPPinRepository<>(); }
}
}Custom extensions (e.g., adding a location field to the "pin" domain) are demonstrated by creating new interfaces ( Pin2 , PinVO2 , PinPO2 ), extending controllers, adapters, and repositories, and providing a specialized PinInstantiatorImpl2 that creates the new builder and view types.
public class PinInstantiatorImpl2 extends PinInstantiatorImpl {
@Override
public PinImpl.Builder newBuilder() { return new PinImpl2.Builder(); }
@Override
public SchrodingerPin.Builder newSchrodingerBuilder() { return new SchrodingerPin2.Builder(); }
@Override
public PinVO newView() { return new PinVO2(); }
}Persistence layer modularity is achieved by defining a custom @ConditionalOnMyBatisPlus annotation that activates MyBatis‑Plus beans only when the configuration flag juejin.repository.mybatis-plus.enabled is true. This allows swapping the repository implementation (e.g., to JPA) without code changes.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ConditionalOnProperty(name = "juejin.repository.mybatis-plus.enabled", havingValue = "true")
public @interface ConditionalOnMyBatisPlus {}In summary, the article shows how to extract repetitive patterns into reusable modules, use Spring conditional beans for flexible configuration, and employ code generation or templates to accelerate development of similar business features.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.