Backend Development 25 min read

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.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Designing a Modular Backend Service with DDD, Caching, Dynamic Proxies and Extensible Configuration

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.

backendDomain-Driven DesignSpringCachingDynamic Proxymodular architecture
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.