Customizing Your Application
Once you have successfully bootstrapped and deployed your core Milesoft application, the next logical step is to expand the platform's schema with your own proprietary business logic and custom entities.
This guide provides a comprehensive, end-to-end walkthrough for implementing a custom business resource called Widget. You will learn how to structure your database model, configure low-latency caches, map between layers using MapStruct, write transactional services, expose secure REST endpoints, and configure role-based access control (RBAC).
Architectural Pattern Overview
To maintain clean separation of concerns, keep your code maintainable, and prevent leakages between layers, Milesoft adheres to a strict multi-tiered architecture:
[ HTTP Request ]
│
▼
1. CONTROLLER ── (DTOs) ──► Validates inputs, handles HTTP status codes, maps to/from Domain.
│
▼
2. SERVICE ── (Domain) ──► Implements business rules, enforces user context/ownership.
│
▼
3. DAO ── (Entities) ─► Orchestrates Firestore queries, handles Spring @Cache layer.
│
▼
[ Cloud Firestore ]
Each tier works with isolated model representations, mapped at the boundaries using compiled MapStruct transformers.
Tenancy Design: Multi-Tenant vs. Single-Tenant Implementation
This guide's walkthrough focuses on the MultiTenant profile (where multiple companies/subscriber accounts share the backend instance but have strict data isolation). However, if you are developing a SingleTenant or MobileOnly application, your data ownership boundaries shift from the Account (organization) level to the User level.
Here is how your implementation patterns differ between profiles:
1. MultiTenant Profile (Default Walkthrough)
- Data Boundary: Tenant Account / Organization level (shared workspace).
- Entity Contract: Implement
OwnedByAccount(fromio.milesoft.primitives.tenant). - Database Fields: The entity contains an
accountIdfield. - Context Resolution: The Service layer binds records to the account context:
entity.setAccountId(currentUser.accountId()); - Ownership Verification: Validates that the active user belongs to the target account.
2. SingleTenant or MobileOnly Profiles
- Data Boundary: Individual User level (strictly private workspace).
- Entity Contract: Implement
OwnedByUser(fromio.milesoft.primitives.user). - Database Fields: Replace
accountIdwith auserIdfield:@NotBlank private String userId; - Context Resolution: The Service layer binds records directly to the authenticated user ID:
entity.setUserId(currentUser.id()); - Ownership Verification: Validates that the active user's ID matches the entity's
userId. - DAO Query Mapping: Queries and indexes filter records by
userIdrather thanaccountId:Query query = reference.whereEqualTo("userId", userId);
1. Low-Latency Caching
High-performance applications require intelligent caching. We manage short-term and long-term cache layers by defining cache-name identifiers in CacheConstants and registering them in CacheConfig.
1.1 Cache Constants
Add your custom cache identifier string to the central constants interface:
public interface CacheConstants {
// Existing cache constants...
String WIDGETS = "widgets";
}
1.2 Cache Registration
Register the cache name in your Spring @Configuration class to ensure the cache manager initializes it at startup:
import static com.yourcompany.myapp.constants.CacheConstants.WIDGETS;
import io.milesoft.stack.config.AbstractCacheConfig;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig extends AbstractCacheConfig {
public CacheConfig() {
super(WIDGETS /*, other caches... */);
}
}
2. Firestore Entity Layer
Our persistent database representation lives inside the firestore.entity package. These objects implement FirestoreEntity and typically map to a tenant account by implementing OwnedByAccount.
Create Widget.java representing the document schema in Cloud Firestore:
import io.milesoft.commons.utils.CollectionUtils;
import io.milesoft.firestore.entity.FirestoreEntity;
import io.milesoft.primitives.tenant.OwnedByAccount;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.ZonedDateTime;
import java.util.Map;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@EqualsAndHashCode
@ToString
public class Widget implements FirestoreEntity, OwnedByAccount {
private String id;
@NotBlank
private String accountId;
@NotBlank
private String name;
private String description;
@NotNull
@Min(0)
private Integer priority;
private Map<String, Object> metadata;
private ZonedDateTime created;
private ZonedDateTime updated;
public Map<String, Object> getMetadata() {
return CollectionUtils.immutableMap(metadata);
}
}
3. Firestore Data Access Object (DAO)
The DAO layer abstracts interaction with Google Cloud Firestore. It extends FirestoreDao<T>, which provides base CRUD queries, and leverages Spring Caching annotations (@Cacheable, @CacheEvict, @Caching) to manage short-lived and long-lived lookups.
💡 Deep Dive: For more information on how the platform configures, runs, and extends Cloud Firestore connections (including multi-region setups and indexes), refer to the Firestore Integration Guide.
Create WidgetDao.java to handle low-level Firestore reads and writes:
import static com.google.cloud.firestore.FieldPath.documentId;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.yourcompany.myapp.constants.CacheConstants.WIDGETS;
import static io.milesoft.commons.constants.Limits.LARGE;
import static io.milesoft.commons.constants.Limits.SMALL;
import static io.milesoft.stack.constants.CacheConstants.LONG_TERM;
import static io.milesoft.stack.constants.CacheConstants.SHORT_TERM;
import com.google.cloud.firestore.Query;
import com.yourcompany.myapp.firestore.entity.Widget;
import io.milesoft.firestore.dao.FirestoreDao;
import io.milesoft.firestore.domain.PagedResults;
import io.milesoft.stack.utils.DaoUtils;
import jakarta.annotation.Nullable;
import java.time.Clock;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Component;
@Component
@CacheConfig(cacheNames = WIDGETS)
public class WidgetDao extends FirestoreDao<Widget> {
private final CacheManager cacheManager;
@Autowired
public WidgetDao(Clock clock, @Qualifier(LONG_TERM) CacheManager cacheManager) {
super(clock);
this.cacheManager = checkNotNull(cacheManager, "cacheManager must not be null");
}
@Override
protected String getCollectionType() {
return "widgets";
}
@Override
protected Class<Widget> getEntityClass() {
return Widget.class;
}
@Cacheable(cacheManager = SHORT_TERM,
key = "'list_v2_' + #accountId + '_' + #cursor + '_' + #limit")
public PagedResults<Widget, String> findByAccountId(String accountId,
@Nullable String cursor, int limit) {
checkArgument(StringUtils.isNotBlank(accountId), "accountId must not be blank");
checkArgument(limit >= SMALL && limit <= LARGE, "limit bounds exceeded");
Query query = reference
.whereEqualTo("accountId", accountId)
.orderBy("priority", Query.Direction.DESCENDING)
.orderBy(documentId(), Query.Direction.ASCENDING)
.limit(limit);
if (StringUtils.isNotBlank(cursor)) {
query = query.startAfter(cursor);
}
return toPagedResults(query, Widget::getId);
}
@Caching(evict = {
@CacheEvict(cacheManager = LONG_TERM, key = "#result.id"),
@CacheEvict(cacheManager = SHORT_TERM, allEntries = true)
})
@Override
public Widget save(Widget entity) {
return super.save(entity);
}
@Cacheable(cacheManager = LONG_TERM, key = "'exists_' + #id")
@Override
public boolean exists(String id) {
return super.exists(id);
}
@Cacheable(cacheManager = LONG_TERM)
@Override
public Optional<Widget> load(String id) {
return super.load(id);
}
public List<Widget> findByIds(Collection<String> ids) {
return DaoUtils.findByIds(super::findByIds, ids, cacheManager, WIDGETS);
}
@Caching(evict = {
@CacheEvict(cacheManager = LONG_TERM, key = "#id"),
@CacheEvict(cacheManager = LONG_TERM, key = "'exists_' + #id"),
@CacheEvict(cacheManager = SHORT_TERM, allEntries = true)
})
@Override
public void delete(String id) {
super.delete(id);
}
}
4. Domain Business Layer
The Domain layer hosts immutable business representations decoupled from database structures. They leverage Lombok's @Builder for clean compilation and enforce integrity checks on creation.
Create Widget.java in your domain package:
import io.milesoft.commons.utils.CollectionUtils;
import java.time.ZonedDateTime;
import java.util.Map;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import lombok.Singular;
import lombok.ToString;
@Getter
@EqualsAndHashCode
@ToString
@Builder(builderClassName = "Builder", toBuilder = true)
public class Widget {
private final String id;
@NonNull
private final String name;
private final String description;
private final int priority;
@Singular("metadatum")
private final Map<String, Object> metadata;
private final ZonedDateTime created;
private final ZonedDateTime updated;
public Map<String, Object> getMetadata() {
return CollectionUtils.immutableMap(metadata);
}
public Builder thaw() {
return toBuilder();
}
}
5. API Data Transfer Objects (DTO)
DTOs represent your JSON request and response contract. Using separated DTOs prevents security issues (like mass-assignment attacks) and isolates API contract changes from database schemas.
5.1 Save Request DTO
Create SaveWidget.java to represent the incoming payload for creation or modification:
import io.milesoft.commons.utils.CollectionUtils;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Map;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@EqualsAndHashCode
@ToString
public class SaveWidget {
@NotBlank
private String name;
private String description;
@NotNull
@Min(0)
private Integer priority;
private Map<String, Object> metadata;
public Map<String, Object> getMetadata() {
return CollectionUtils.immutableMap(metadata);
}
}
5.2 Response DTO
Create Widget.java representing the returned JSON model:
import io.milesoft.commons.utils.CollectionUtils;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.ZonedDateTime;
import java.util.Map;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@EqualsAndHashCode
@ToString
public class Widget {
@NotBlank
private String id;
@NotBlank
private String name;
private String description;
@NotNull
@Min(0)
private Integer priority;
private Map<String, Object> metadata;
@NotNull
private ZonedDateTime created;
@NotNull
private ZonedDateTime updated;
public Map<String, Object> getMetadata() {
return CollectionUtils.immutableMap(metadata);
}
}
5.3 Collection List DTO
Create Widgets.java to represent a paginated response:
import java.util.List;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@EqualsAndHashCode
@ToString
public class Widgets {
private List<Widget> widgets;
private String cursor;
}
6. MapStruct Transformers
We use MapStruct to generate ultra-fast, type-safe mapping implementations at compile-time, completely bypassing slow reflection.
6.1 Entity Transformer
Create WidgetEntityTransformer.java to map between persistent Firestore entities and domain objects:
import com.yourcompany.myapp.domain.Widget;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface WidgetEntityTransformer {
Widget fromEntity(com.yourcompany.myapp.firestore.entity.Widget entity);
@Mapping(target = "accountId", ignore = true)
com.yourcompany.myapp.firestore.entity.Widget toEntity(Widget widget);
}
6.2 DTO Transformer
Create WidgetDtoTransformer.java to map between external API transfer models and domain objects:
import com.yourcompany.myapp.domain.Widget;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface WidgetDtoTransformer {
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "updated", ignore = true)
Widget fromDto(com.yourcompany.myapp.api.v2.dto.SaveWidget dto);
Widget fromDto(com.yourcompany.myapp.api.v2.dto.Widget dto);
com.yourcompany.myapp.api.v2.dto.Widget toDto(Widget widget);
}
7. Service Layer
The Service tier contains your core business logic, orchestrates data flow, handles current session context (such as identifying the logged-in user or account context), and verifies document tenancy.
Create WidgetService.java to execute these transactions securely:
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import com.yourcompany.myapp.domain.Widget;
import com.yourcompany.myapp.firestore.dao.WidgetDao;
import com.yourcompany.myapp.firestore.transformers.WidgetEntityTransformer;
import io.milesoft.commons.utils.StreamUtils;
import io.milesoft.firestore.domain.PagedResults;
import io.milesoft.stack.domain.CurrentUser;
import io.milesoft.stack.utils.OwnershipUtils;
import jakarta.annotation.Nullable;
import java.util.Collection;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.mapstruct.factory.Mappers;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class WidgetService {
private final CurrentUser currentUser;
private final WidgetDao widgetDao;
private final WidgetEntityTransformer widgetEntityTransformer;
@Autowired
public WidgetService(CurrentUser currentUser, WidgetDao widgetDao) {
this.currentUser = checkNotNull(currentUser, "currentUser must not be null");
this.widgetDao = checkNotNull(widgetDao, "widgetDao must not be null");
this.widgetEntityTransformer = Mappers.getMapper(WidgetEntityTransformer.class);
}
private com.yourcompany.myapp.firestore.entity.Widget loadAndVerify(String widgetId) {
// Automatically checks if document belongs to the active user's Tenant Account context
return OwnershipUtils.loadAndVerifyOwnership(currentUser, widgetDao, "widget", widgetId);
}
public PagedResults<Widget, String> findWidgets(@Nullable String cursor, int limit) {
final String accountId = currentUser.accountId();
final PagedResults<com.yourcompany.myapp.firestore.entity.Widget, String> results =
widgetDao.findByAccountId(accountId, cursor, limit);
return PagedResults.<Widget, String>builder()
.cursor(results.getCursor())
.entities(StreamUtils.stream(results.getEntities())
.map(widgetEntityTransformer::fromEntity)
.toList())
.build();
}
public Widget loadWidget(String widgetId) {
checkArgument(StringUtils.isNotBlank(widgetId), "widgetId must not be blank");
return widgetEntityTransformer.fromEntity(loadAndVerify(widgetId));
}
public List<Widget> loadWidgets(Collection<String> ids) {
return StreamUtils.stream(widgetDao.findByIds(ids))
.filter(currentUser::owns) // Verify individual multi-tenant boundaries
.map(widgetEntityTransformer::fromEntity)
.toList();
}
public Widget createWidget(Widget widget) {
checkNotNull(widget, "widget must not be null");
final com.yourcompany.myapp.firestore.entity.Widget entity =
new com.yourcompany.myapp.firestore.entity.Widget();
entity.setAccountId(currentUser.accountId());
sync(entity, widget);
return widgetEntityTransformer.fromEntity(widgetDao.save(entity));
}
public Widget updateWidget(String widgetId, Widget widget) {
checkArgument(StringUtils.isNotBlank(widgetId), "widgetId must not be blank");
checkNotNull(widget, "widget must not be null");
final com.yourcompany.myapp.firestore.entity.Widget entity = loadAndVerify(widgetId);
sync(entity, widget);
return widgetEntityTransformer.fromEntity(widgetDao.save(entity));
}
private void sync(com.yourcompany.myapp.firestore.entity.Widget entity, Widget widget) {
entity.setName(widget.getName());
entity.setDescription(widget.getDescription());
entity.setPriority(widget.getPriority());
entity.setMetadata(widget.getMetadata());
}
public void deleteWidget(String widgetId) {
loadAndVerify(widgetId);
widgetDao.delete(widgetId);
}
}
8. REST Controller
The Controller acts as the entry point of your REST API. It maps inbound routes, parses HTTP parameters, delegates business logic execution to the service tier, and applies validation.
Create WidgetController.java to handle HTTP request mappings:
import static com.google.common.base.Preconditions.checkNotNull;
import static io.milesoft.commons.constants.Limits.LARGE;
import static io.milesoft.commons.constants.Limits.MEDIUM;
import static io.milesoft.commons.constants.Limits.SMALL;
import com.yourcompany.myapp.api.v2.dto.Ids;
import com.yourcompany.myapp.api.v2.dto.SaveWidget;
import com.yourcompany.myapp.api.v2.dto.Widget;
import com.yourcompany.myapp.api.v2.dto.Widgets;
import com.yourcompany.myapp.api.v2.transformers.WidgetDtoTransformer;
import com.yourcompany.myapp.services.WidgetService;
import io.milesoft.commons.utils.StreamUtils;
import io.milesoft.firestore.domain.PagedResults;
import io.milesoft.stack.aop.Audited;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import org.apache.commons.lang3.ObjectUtils;
import org.mapstruct.factory.Mappers;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Audited
@RestController
@RequestMapping("/api/v1/widget")
public class WidgetController {
private final WidgetService widgetService;
private final WidgetDtoTransformer widgetDtoTransformer;
@Autowired
public WidgetController(WidgetService widgetService) {
this.widgetService = checkNotNull(widgetService, "widgetService must not be null");
this.widgetDtoTransformer = Mappers.getMapper(WidgetDtoTransformer.class);
}
@RequestMapping(path = "/list", method = RequestMethod.GET,
produces = MediaType.APPLICATION_JSON_VALUE)
public Widgets list(
@RequestParam(required = false) String cursor,
@RequestParam(required = false) @Min(SMALL) @Max(LARGE) Integer limit) {
final int limitToUse = ObjectUtils.defaultIfNull(limit, MEDIUM);
final PagedResults<com.yourcompany.myapp.domain.Widget, String> results =
widgetService.findWidgets(cursor, limitToUse);
final Widgets response = new Widgets();
response.setCursor(results.getCursor());
response.setWidgets(StreamUtils.stream(results.getEntities())
.map(widgetDtoTransformer::toDto)
.toList());
return response;
}
@RequestMapping(path = "/load", method = RequestMethod.POST,
consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public Widgets load(
@RequestBody @Valid @NotNull Ids request) {
checkNotNull(request, "request must not be null");
final List<com.yourcompany.myapp.domain.Widget> entities =
widgetService.loadWidgets(request.getIds());
final Widgets response = new Widgets();
response.setWidgets(StreamUtils.stream(entities)
.map(widgetDtoTransformer::toDto)
.toList());
return response;
}
@RequestMapping(path = "/", method = RequestMethod.POST,
consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public Widget create(
@RequestBody @Valid @NotNull SaveWidget request) {
final com.yourcompany.myapp.domain.Widget command = widgetDtoTransformer.fromDto(request);
final com.yourcompany.myapp.domain.Widget created = widgetService.createWidget(command);
return widgetDtoTransformer.toDto(created);
}
@RequestMapping(path = "/{widgetId}/", method = RequestMethod.PUT,
consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public Widget modify(
@PathVariable("widgetId") @NotBlank String widgetId,
@RequestBody @Valid @NotNull SaveWidget request) {
final com.yourcompany.myapp.domain.Widget command = widgetDtoTransformer.fromDto(request);
final com.yourcompany.myapp.domain.Widget modified = widgetService.updateWidget(widgetId, command);
return widgetDtoTransformer.toDto(modified);
}
@RequestMapping(path = "/{widgetId}/", method = RequestMethod.DELETE)
public void delete(
@PathVariable("widgetId") @NotBlank String widgetId) {
widgetService.deleteWidget(widgetId);
}
}
9. Security Configurations
All platform endpoints reject requests by default unless explicitly configured. Configure role-based authorization rules for the widget endpoints inside SecurityConfig.
Find SecurityConfig.java in your package structure and add the matcher rule inside the customize method:
import static io.milesoft.stack.constants.RoleConstants.ADMIN;
import static io.milesoft.stack.constants.RoleConstants.USER;
import org.springframework.http.HttpMethod;
// ... other imports
@Configuration
@EnableWebSecurity
public class SecurityConfig extends AbstractSecurityConfig {
// ... Constructor
@Override
protected void customize(
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry customizer) {
customizer
// Existing matchers...
// Allow authenticated users to read widgets (includes admins)
.requestMatchers(HttpMethod.GET, "/api/v1/widget/**").hasAuthority(USER)
// But only allow authenticated admins to write/delete widgets
.requestMatchers("/api/v1/widget/**").hasAuthority(ADMIN)
// Fallback for everything else
.requestMatchers("/api/v1/**").authenticated();
}
}