Contentful is a leading headless CMS that provides content infrastructure through APIs. The Contentful Delivery API allows Java applications to retrieve published content for display across websites, mobile apps, and other digital channels. Let's explore how to build robust Java applications that leverage Contentful's content delivery capabilities.
Why Contentful for Java Applications?
Benefits for Java developers:
- API-First Architecture - Content delivered via RESTful APIs
- Strong Typing - Content models with defined fields and validation
- Multi-Language Support - Built-in internationalization
- Rich Media Handling - Image optimization and transformation
- Scalability - CDN-backed global content delivery
- Developer Experience - Excellent SDKs and documentation
Contentful Java SDK Setup
1. Maven Dependencies
<!-- pom.xml --> <dependencies> <dependency> <groupId>com.contentful.java</groupId> <artifactId>java-sdk</artifactId> <version>10.9.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> <version>2.15.2</version> </dependency> </dependencies>
2. Contentful Configuration
@Configuration
@ConfigurationProperties(prefix = "contentful")
@Data
public class ContentfulConfig {
private String spaceId;
private String deliveryToken;
private String environment = "master";
private boolean preview = false;
@Bean
public CDAClient contentfulClient() {
CDAClient.Builder builder = CDAClient.builder()
.setSpace(spaceId)
.setToken(deliveryToken)
.setEnvironment(environment);
if (preview) {
builder.usePreviewApi();
}
return builder.build();
}
}
3. Application Properties
# application.yml
contentful:
space-id: ${CONTENTFUL_SPACE_ID}
delivery-token: ${CONTENTFUL_DELIVERY_TOKEN}
environment: master
preview: false
Content Model Mapping
1. Base Content Entity
@Data
public abstract class ContentfulEntity {
protected String id;
protected String type;
protected LocalDateTime createdAt;
protected LocalDateTime updatedAt;
protected String locale;
protected Map<String, Object> fields = new HashMap<>();
public <T> T getField(String fieldName, Class<T> clazz) {
Object value = fields.get(fieldName);
return clazz.isInstance(value) ? clazz.cast(value) : null;
}
public String getFieldAsString(String fieldName) {
return getField(fieldName, String.class);
}
public List<String> getFieldAsStringList(String fieldName) {
Object value = fields.get(fieldName);
if (value instanceof List) {
@SuppressWarnings("unchecked")
List<String> list = (List<String>) value;
return list;
}
return Collections.emptyList();
}
public <T> List<T> getFieldAsList(String fieldName, Class<T> clazz) {
Object value = fields.get(fieldName);
if (value instanceof List) {
@SuppressWarnings("unchecked")
List<T> list = (List<T>) value;
return list;
}
return Collections.emptyList();
}
}
2. Content Type Models
@Data
@EqualsAndHashCode(callSuper = true)
public class Article extends ContentfulEntity {
public static final String CONTENT_TYPE = "article";
public String getTitle() {
return getFieldAsString("title");
}
public String getSlug() {
return getFieldAsString("slug");
}
public String getSummary() {
return getFieldAsString("summary");
}
public String getContent() {
return getFieldAsString("content");
}
public LocalDateTime getPublishDate() {
return getField("publishDate", LocalDateTime.class);
}
public List<String> getTags() {
return getFieldAsStringList("tags");
}
public ContentfulImage getFeaturedImage() {
return getField("featuredImage", ContentfulImage.class);
}
public List<Author> getAuthors() {
return getFieldAsList("authors", Author.class);
}
public String getReadingTime() {
Integer minutes = getField("readingTime", Integer.class);
return minutes != null ? minutes + " min read" : null;
}
}
@Data
@EqualsAndHashCode(callSuper = true)
public class Author extends ContentfulEntity {
public static final String CONTENT_TYPE = "author";
public String getName() {
return getFieldAsString("name");
}
public String getBio() {
return getFieldAsString("bio");
}
public ContentfulImage getProfilePicture() {
return getField("profilePicture", ContentfulImage.class);
}
public String getEmail() {
return getFieldAsString("email");
}
public List<String> getSocialLinks() {
return getFieldAsStringList("socialLinks");
}
}
@Data
public class ContentfulImage {
private String id;
private String title;
private String description;
private String url;
private Integer width;
private Integer height;
private String contentType;
public String getUrlWithWidth(int width) {
return url + "?w=" + width;
}
public String getUrlWithHeight(int height) {
return url + "?h=" + height;
}
public String getUrlWithSize(int width, int height) {
return url + "?w=" + width + "&h=" + height;
}
public String getUrlWithFormat(String format) {
return url + "?fm=" + format;
}
public String getWebPUrl() {
return getUrlWithFormat("webp");
}
}
Contentful Service Layer
1. Core Content Service
@Service
@Slf4j
public class ContentfulService {
private final CDAClient contentfulClient;
private final ObjectMapper objectMapper;
public ContentfulService(CDAClient contentfulClient) {
this.contentfulClient = contentfulClient;
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new JavaTimeModule());
this.objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
}
// Entry retrieval by ID
public <T extends ContentfulEntity> Optional<T> getEntry(String entryId, Class<T> clazz) {
try {
CDAEntry entry = contentfulClient.fetch(CDAEntry.class)
.one(entryId);
return Optional.of(mapToEntity(entry, clazz));
} catch (CDAResourceNotFoundException e) {
log.warn("Entry not found: {}", entryId);
return Optional.empty();
} catch (Exception e) {
log.error("Failed to fetch entry: {}", entryId, e);
throw new ContentfulException("Failed to fetch entry: " + entryId, e);
}
}
// Get all entries of a content type
public <T extends ContentfulEntity> List<T> getEntries(Class<T> clazz) {
try {
String contentType = getContentTypeFromClass(clazz);
CDAResource[] resources = contentfulClient.fetch(CDAEntry.class)
.withContentType(contentType)
.all()
.items();
return Arrays.stream(resources)
.filter(resource -> resource instanceof CDAEntry)
.map(resource -> (CDAEntry) resource)
.map(entry -> mapToEntity(entry, clazz))
.collect(Collectors.toList());
} catch (Exception e) {
log.error("Failed to fetch entries for type: {}", clazz.getSimpleName(), e);
throw new ContentfulException("Failed to fetch entries", e);
}
}
// Get entries with filtering and sorting
public <T extends ContentfulEntity> ContentfulResponse<T> getEntries(
Class<T> clazz,
ContentQuery query) {
try {
String contentType = getContentTypeFromClass(clazz);
CDAArray array = buildQuery(contentType, query)
.all();
List<T> items = Arrays.stream(array.items())
.filter(resource -> resource instanceof CDAEntry)
.map(resource -> (CDAEntry) resource)
.map(entry -> mapToEntity(entry, clazz))
.collect(Collectors.toList());
return ContentfulResponse.<T>builder()
.items(items)
.total(array.total())
.skip(array.skip())
.limit(array.limit())
.build();
} catch (Exception e) {
log.error("Failed to fetch entries with query: {}", query, e);
throw new ContentfulException("Failed to fetch entries with query", e);
}
}
// Search entries by field value
public <T extends ContentfulEntity> List<T> searchEntries(
Class<T> clazz,
String field,
String value) {
try {
String contentType = getContentTypeFromClass(clazz);
CDAResource[] resources = contentfulClient.fetch(CDAEntry.class)
.withContentType(contentType)
.where("fields." + field, value)
.all()
.items();
return Arrays.stream(resources)
.filter(resource -> resource instanceof CDAEntry)
.map(resource -> (CDAEntry) resource)
.map(entry -> mapToEntity(entry, clazz))
.collect(Collectors.toList());
} catch (Exception e) {
log.error("Failed to search entries: {}.{} = {}",
clazz.getSimpleName(), field, value, e);
throw new ContentfulException("Search failed", e);
}
}
// Get entries with included references
public <T extends ContentfulEntity> List<T> getEntriesWithReferences(
Class<T> clazz,
String... includeFields) {
try {
String contentType = getContentTypeFromClass(clazz);
FetchQuery<CDAEntry> query = contentfulClient.fetch(CDAEntry.class)
.withContentType(contentType);
// Include references
for (String field : includeFields) {
query = query.include(1); // Include 1 level of references
}
CDAResource[] resources = query.all().items();
return Arrays.stream(resources)
.filter(resource -> resource instanceof CDAEntry)
.map(resource -> (CDAEntry) resource)
.map(entry -> mapToEntity(entry, clazz))
.collect(Collectors.toList());
} catch (Exception e) {
log.error("Failed to fetch entries with references: {}", clazz.getSimpleName(), e);
throw new ContentfulException("Failed to fetch entries with references", e);
}
}
// Helper methods
private <T extends ContentfulEntity> T mapToEntity(CDAEntry entry, Class<T> clazz) {
try {
T entity = clazz.getDeclaredConstructor().newInstance();
entity.setId(entry.id());
entity.setType(entry.contentType().name());
entity.setCreatedAt(entry.createdAt());
entity.setUpdatedAt(entry.updatedAt());
entity.setLocale(entry.locale());
// Map fields
Map<String, Object> fields = new HashMap<>();
for (String fieldName : entry.attrs().keySet()) {
Object fieldValue = extractFieldValue(entry, fieldName);
fields.put(fieldName, fieldValue);
}
entity.setFields(fields);
return entity;
} catch (Exception e) {
throw new ContentfulException("Failed to map entry to entity", e);
}
}
private Object extractFieldValue(CDAEntry entry, String fieldName) {
try {
Object value = entry.getField(fieldName);
if (value instanceof CDAEntry) {
// Handle linked entries
CDAEntry linkedEntry = (CDAEntry) value;
return mapToEntity(linkedEntry, ContentfulEntity.class);
} else if (value instanceof CDAAsset) {
// Handle assets (images, files)
CDAAsset asset = (CDAAsset) value;
return mapToImage(asset);
} else if (value instanceof List) {
// Handle arrays of linked entries or assets
@SuppressWarnings("unchecked")
List<Object> list = (List<Object>) value;
return list.stream()
.map(item -> {
if (item instanceof CDAEntry) {
return mapToEntity((CDAEntry) item, ContentfulEntity.class);
} else if (item instanceof CDAAsset) {
return mapToImage((CDAAsset) item);
}
return item;
})
.collect(Collectors.toList());
}
return value;
} catch (Exception e) {
log.warn("Failed to extract field value: {}", fieldName, e);
return null;
}
}
private ContentfulImage mapToImage(CDAAsset asset) {
ContentfulImage image = new ContentfulImage();
image.setId(asset.id());
image.setTitle(asset.title());
image.setDescription(asset.description());
CDAFile file = asset.file();
if (file != null) {
image.setUrl(file.url());
image.setContentType(file.contentType());
CDADetails details = file.details();
if (details != null) {
image.setWidth(details.image().width());
image.setHeight(details.image().height());
}
}
return image;
}
private String getContentTypeFromClass(Class<?> clazz) {
try {
java.lang.reflect.Field contentTypeField = clazz.getDeclaredField("CONTENT_TYPE");
return (String) contentTypeField.get(null);
} catch (Exception e) {
throw new ContentfulException("Content type not defined for class: " + clazz.getSimpleName());
}
}
private FetchQuery<CDAEntry> buildQuery(String contentType, ContentQuery query) {
FetchQuery<CDAEntry> fetchQuery = contentfulClient.fetch(CDAEntry.class)
.withContentType(contentType);
// Apply filters
if (query.getFilters() != null) {
for (Map.Entry<String, String> filter : query.getFilters().entrySet()) {
fetchQuery = fetchQuery.where("fields." + filter.getKey(), filter.getValue());
}
}
// Apply ordering
if (query.getOrderBy() != null) {
String orderField = query.getOrderBy();
if (query.isOrderDesc()) {
fetchQuery = fetchQuery.orderBy("-fields." + orderField);
} else {
fetchQuery = fetchQuery.orderBy("fields." + orderField);
}
}
// Apply pagination
if (query.getSkip() != null) {
fetchQuery = fetchQuery.skip(query.getSkip());
}
if (query.getLimit() != null) {
fetchQuery = fetchQuery.limit(query.getLimit());
}
// Include references if requested
if (query.getIncludeDepth() != null) {
fetchQuery = fetchQuery.include(query.getIncludeDepth());
}
return fetchQuery;
}
// Query DTO
@Data
@Builder
public static class ContentQuery {
private Map<String, String> filters;
private String orderBy;
private boolean orderDesc;
private Integer skip;
private Integer limit;
private Integer includeDepth;
private String locale;
}
// Response DTO
@Data
@Builder
public static class ContentfulResponse<T> {
private List<T> items;
private int total;
private int skip;
private int limit;
}
}
Caching Layer for Performance
1. Content Caching Service
@Service
public class ContentCacheService {
private final CacheManager cacheManager;
private final ContentfulService contentfulService;
private static final String ARTICLE_CACHE = "articles";
private static final String AUTHOR_CACHE = "authors";
private static final Duration CACHE_TTL = Duration.ofHours(1);
public ContentCacheService(CacheManager cacheManager, ContentfulService contentfulService) {
this.cacheManager = cacheManager;
this.contentfulService = contentfulService;
}
@Cacheable(value = ARTICLE_CACHE, key = "#id", unless = "#result == null")
public Optional<Article> getArticle(String id) {
return contentfulService.getEntry(id, Article.class);
}
@Cacheable(value = ARTICLE_CACHE, key = "'all'")
public List<Article> getAllArticles() {
return contentfulService.getEntries(Article.class);
}
@Cacheable(value = ARTICLE_CACHE, key = "#slug")
public Optional<Article> getArticleBySlug(String slug) {
List<Article> articles = contentfulService.searchEntries(Article.class, "slug", slug);
return articles.stream().findFirst();
}
@Cacheable(value = AUTHOR_CACHE, key = "#id", unless = "#result == null")
public Optional<Author> getAuthor(String id) {
return contentfulService.getEntry(id, Author.class);
}
@CacheEvict(value = {ARTICLE_CACHE, AUTHOR_CACHE}, allEntries = true)
public void clearCache() {
log.info("Content cache cleared");
}
@Scheduled(fixedRate = 3600000) // Clear cache every hour
public void scheduledCacheClear() {
clearCache();
}
}
REST Controller
1. Content Delivery API
@RestController
@RequestMapping("/api/content")
@Slf4j
public class ContentController {
private final ContentfulService contentfulService;
private final ContentCacheService cacheService;
public ContentController(ContentfulService contentfulService,
ContentCacheService cacheService) {
this.contentfulService = contentfulService;
this.cacheService = cacheService;
}
@GetMapping("/articles")
public ResponseEntity<ContentfulResponse<Article>> getArticles(
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer size,
@RequestParam(required = false) String tag,
@RequestParam(required = false) String author,
@RequestParam(required = false, defaultValue = "publishDate") String sort,
@RequestParam(required = false, defaultValue = "desc") String order) {
try {
ContentfulService.ContentQuery query = buildArticleQuery(
page, size, tag, author, sort, order);
ContentfulService.ContentfulResponse<Article> response =
contentfulService.getEntries(Article.class, query);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to fetch articles", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/articles/{id}")
public ResponseEntity<Article> getArticle(@PathVariable String id) {
try {
Optional<Article> article = cacheService.getArticle(id);
return article.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
} catch (Exception e) {
log.error("Failed to fetch article: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/articles/slug/{slug}")
public ResponseEntity<Article> getArticleBySlug(@PathVariable String slug) {
try {
Optional<Article> article = cacheService.getArticleBySlug(slug);
return article.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
} catch (Exception e) {
log.error("Failed to fetch article by slug: {}", slug, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/authors")
public ResponseEntity<List<Author>> getAuthors() {
try {
List<Author> authors = contentfulService.getEntries(Author.class);
return ResponseEntity.ok(authors);
} catch (Exception e) {
log.error("Failed to fetch authors", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/authors/{id}")
public ResponseEntity<Author> getAuthor(@PathVariable String id) {
try {
Optional<Author> author = cacheService.getAuthor(id);
return author.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
} catch (Exception e) {
log.error("Failed to fetch author: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/search")
public ResponseEntity<List<ContentfulEntity>> searchContent(
@RequestParam String q,
@RequestParam(required = false) String type) {
try {
List<ContentfulEntity> results = new ArrayList<>();
if (type == null || "article".equals(type)) {
List<Article> articles = contentfulService.searchEntries(
Article.class, "title", q);
results.addAll(articles);
}
if (type == null || "author".equals(type)) {
List<Author> authors = contentfulService.searchEntries(
Author.class, "name", q);
results.addAll(authors);
}
return ResponseEntity.ok(results);
} catch (Exception e) {
log.error("Search failed for query: {}", q, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/cache/clear")
public ResponseEntity<Void> clearCache() {
cacheService.clearCache();
return ResponseEntity.ok().build();
}
private ContentfulService.ContentQuery buildArticleQuery(
Integer page, Integer size, String tag, String author,
String sort, String order) {
Map<String, String> filters = new HashMap<>();
if (tag != null) {
filters.put("tags", tag);
}
return ContentfulService.ContentQuery.builder()
.filters(filters)
.orderBy(sort)
.orderDesc("desc".equalsIgnoreCase(order))
.skip(page != null && size != null ? page * size : null)
.limit(size)
.includeDepth(2) // Include authors and images
.build();
}
}
Error Handling and Resilience
1. Custom Exceptions
public class ContentfulException extends RuntimeException {
public ContentfulException(String message) {
super(message);
}
public ContentfulException(String message, Throwable cause) {
super(message, cause);
}
}
@ControllerAdvice
public class ContentfulExceptionHandler {
@ExceptionHandler(ContentfulException.class)
public ResponseEntity<Map<String, String>> handleContentfulException(
ContentfulException e) {
log.error("Contentful API error", e);
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "content_delivery_error");
errorResponse.put("message", "Failed to fetch content");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(errorResponse);
}
@ExceptionHandler(CDAResourceNotFoundException.class)
public ResponseEntity<Map<String, String>> handleResourceNotFound(
CDAResourceNotFoundException e) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "content_not_found");
errorResponse.put("message", "Requested content not found");
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(errorResponse);
}
}
Testing Contentful Integration
1. Unit Tests with Mocking
@ExtendWith(MockitoExtension.class)
class ContentfulServiceTest {
@Mock
private CDAClient contentfulClient;
@InjectMocks
private ContentfulService contentfulService;
@Test
void shouldFetchArticleSuccessfully() {
// Given
String entryId = "article-123";
CDAEntry mockEntry = createMockArticleEntry();
when(contentfulClient.fetch(CDAEntry.class).one(entryId))
.thenReturn(mockEntry);
// When
Optional<Article> result = contentfulService.getEntry(entryId, Article.class);
// Then
assertTrue(result.isPresent());
assertEquals("Test Article", result.get().getTitle());
assertEquals("test-article", result.get().getSlug());
}
@Test
void shouldReturnEmptyForNotFoundEntry() {
// Given
String entryId = "non-existent";
when(contentfulClient.fetch(CDAEntry.class).one(entryId))
.thenThrow(new CDAResourceNotFoundException("Entry not found"));
// When
Optional<Article> result = contentfulService.getEntry(entryId, Article.class);
// Then
assertFalse(result.isPresent());
}
private CDAEntry createMockArticleEntry() {
// Implementation to create mock CDAEntry
// This would typically use a mocking framework or builder pattern
return mock(CDAEntry.class);
}
}
Best Practices for Contentful Integration
- Caching Strategy - Implement appropriate caching to reduce API calls
- Error Handling - Gracefully handle Contentful API failures
- Content Modeling - Design content types that match your application needs
- Performance - Use includes wisely and limit field selections
- Security - Keep delivery tokens secure and use environment-specific spaces
- Monitoring - Track API usage and performance metrics
- Localization - Implement proper locale handling for multilingual content
Conclusion
Integrating Contentful Delivery API with Java applications provides a powerful content management solution that separates content from presentation. By leveraging Contentful's Java SDK and implementing proper service layers, caching, and error handling, Java developers can:
- Deliver Content Efficiently - Serve content via CDN with optimal performance
- Enable Content Flexibility - Allow content editors to manage content without code changes
- Support Multiple Channels - Use the same content across web, mobile, and other platforms
- Scale Effortlessly - Handle traffic spikes with Contentful's global infrastructure
- Maintain Developer Productivity - Use strong typing and familiar Java patterns
The combination of Contentful's robust content infrastructure with Java's enterprise capabilities creates a powerful foundation for modern content-driven applications. By implementing the patterns shown here, Java teams can build scalable, maintainable applications that leverage the full power of headless CMS architecture.