GDPR Compliance for Java Applications
Implementing the parts of GDPR that reach the code: data subject access and export, consent records, erasure through anonymisation, retention jobs, and breach notification.
On this page
GDPR is mostly process and legal interpretation, but several obligations land directly in the code: producing everything you hold about a person, deleting it on request, proving consent, and identifying who was affected by a breach. This covers those.
This is engineering guidance, not legal advice — the interpretation of what any obligation requires belongs with your legal or compliance function.
Key Takeaways
- Build a data map first — you cannot serve rights over data you have not located.
- Access and portability need a machine-readable export assembled across services.
- Erasure is often anonymisation, because other law requires retention.
- Consent must be recorded with timestamp, version and a withdrawal path.
- Breach tooling must exist before the 72-hour clock starts.
The data map
Every obligation depends on knowing where personal data lives. Record it:
// Annotate fields so the inventory is derivable from code rather than a
// spreadsheet that drifts out of date.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PersonalData {
Category category(); // IDENTITY, CONTACT, FINANCIAL, BEHAVIOURAL
boolean special() default false; // special-category data under Article 9
String retention(); // "7 years", "until account closure"
}
@Entity
public class Customer {
@PersonalData(category = Category.IDENTITY, retention = "until account closure")
private String fullName;
@PersonalData(category = Category.CONTACT, retention = "until account closure")
private String email;
@PersonalData(category = Category.FINANCIAL, retention = "7 years (tax law)")
private String taxId;
}Deriving the inventory from annotations means a developer adding a personal-data field is prompted to classify it, and the map stays current. A spreadsheet maintained by hand is out of date within a quarter.
Data subject access and portability
@Service
public class DataExportService {
private final List<PersonalDataProvider> providers; // one per service or domain
public ExportPackage export(String subjectId) {
// Identity verification happens before this — releasing someone's data
// to an impersonator is itself a breach.
var sections = providers.stream()
.map(provider -> provider.exportFor(subjectId))
.filter(section -> !section.isEmpty())
.toList();
auditLog.record("gdpr.export", subjectId);
// Machine-readable and structured, per the portability right.
return new ExportPackage(subjectId, Instant.now(), sections);
}
}
// Each service implements this, so a new service that holds personal data
// cannot be forgotten — it either implements the interface or it does not
// hold personal data.
public interface PersonalDataProvider {
DataSection exportFor(String subjectId);
}The interface is the mechanism that keeps this correct as the system grows. A new service holding personal data implements it or it does not, and the compiler makes that a visible decision rather than an omission.
Erasure
Erasure is not always deletion, because other legal obligations often require retention:
@Service
public class ErasureService {
@Transactional
public ErasureResult erase(String subjectId) {
var result = new ErasureResult();
// Data with no retention obligation: hard delete.
marketingRepository.deleteBySubject(subjectId);
behaviouralRepository.deleteBySubject(subjectId);
result.deleted("marketing", "behavioural");
// Data legally required to be retained: anonymise so it no longer
// relates to an identifiable person, but the record survives for
// the financial and legal purposes that mandate keeping it.
orderRepository.findBySubject(subjectId).forEach(order -> {
order.anonymise(); // name -> "REDACTED", email -> null, address -> region only
result.anonymised("orders");
});
// Some data enters a hold rather than being erased: an active dispute,
// a legal obligation to preserve. Record why.
if (disputeService.hasActiveDispute(subjectId)) {
result.retained("orders-in-dispute", "active legal dispute");
}
auditLog.record("gdpr.erasure", subjectId, result.summary());
return result;
}
}The erasure response must be honest about what was retained and why. Telling a subject their data was deleted when a tax record was anonymised and kept is both inaccurate and itself a compliance problem.
A common pattern is a grace period: soft-delete, wait 30 days in case the request was a mistake or fraudulent, then anonymise or hard-delete. Balance this against the obligation to act "without undue delay".
Consent
@Entity
public class ConsentRecord {
@Id private UUID id;
private String subjectId;
@Enumerated(EnumType.STRING)
private ConsentPurpose purpose; // ANALYTICS, MARKETING, PERSONALISATION
private boolean granted;
// The exact wording the subject agreed to, so you can prove WHAT was
// consented to, not just that something was.
private String policyVersion;
private Instant timestamp;
private String ipAddress;
private String mechanism; // "cookie banner", "signup checkbox"
}Consent must be specific, informed, freely given and withdrawable, and you must be able to prove it. That means recording not just that consent was given but which version of which policy, when, and how — because a policy that changes materially invalidates consent given to the old one.
Withdrawal must be as easy as granting. A one-click unsubscribe, a cookie-preferences page that actually stops the processing, not a support ticket.
Retention
@Component
public class RetentionEnforcementJob {
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "retentionEnforcement", lockAtMostFor = "PT1H")
public void enforce() {
// "We keep data as long as necessary" is not a policy unless a job
// enforces it. Keeping data past its stated retention is a violation
// even if nobody ever looks at it.
retentionPolicies.forEach(policy -> {
var expired = policy.findExpired(Instant.now());
expired.forEach(record -> {
archiveIfRequired(record); // legal hold check first
policy.erase(record);
});
log.info("retention: erased {} {} records", expired.size(), policy.dataType());
});
}
}A retention policy that is written but not enforced is worse than none, because it documents an obligation you are demonstrably not meeting. The scheduled job is what turns the policy into reality.
Breach notification
The 72-hour deadline means the tooling must exist in advance:
@Service
public class BreachResponseService {
/** Who was affected — the question you must answer within 72 hours. */
public AffectedSubjects assess(BreachScope scope) {
return switch (scope.type()) {
case DATABASE_EXPOSURE -> new AffectedSubjects(
subjectRepository.findInTables(scope.affectedTables()),
scope.dataCategories());
case CREDENTIAL_LEAK -> new AffectedSubjects(
subjectRepository.findByCredentialSource(scope.source()),
Set.of(Category.IDENTITY));
case LOG_EXPOSURE -> logAnalyser.subjectsInExposedLogs(scope.timeRange());
};
}
}The audit log and the data map are what make this answerable quickly. Without them, "which customers' data was in that table" becomes days of investigation against a 72-hour clock — which is why both need to exist before an incident.
Notification has two audiences: the supervisory authority within 72 hours, and affected individuals "without undue delay" where the risk is high. Prepare templates for both in advance.
Privacy by design
The obligations above are far cheaper to meet when the system was built for them. Collect only the data you need for a stated purpose. Set a retention period at the point of collection, not later. Make the subject id a consistent key across services so a fan-out is possible. And keep an audit trail of access to personal data, because "who looked at this record" is a question that will be asked.
Retrofitting these into a system that scattered personal data without a map is the expensive path, and it is the one most organisations end up on.
What to take away
Build a data map so every obligation has something to operate over. Implement export as a fan-out across services behind a common interface, and erasure as deletion or anonymisation depending on what other law requires. Record consent with its policy version, enforce retention with a job, and build breach-assessment tooling before the 72-hour clock can start.
Frequently Asked Questions
Does erasure mean deleting the row?
How do I export everything for a subject access request?
What is the 72-hour rule?
Related tutorials
- Audit Logging & SIEM IntegrationBuilding an audit trail that stands up to scrutiny: what to record, a structured event format, hash-chained tamper evidence, Hibernate Envers, and shipping to a SIEM.
- Zero-Trust ArchitectureBuilding zero-trust in practice: the NIST model, workload identity with SPIFFE, mTLS everywhere, per-request authorization, micro-segmentation, and just-in-time access.
- Cryptographic Key ManagementManaging keys across their lifecycle: generation, storage in HSMs and KMS, envelope encryption, rotation without re-encrypting everything, and the key hierarchy that makes it work.
- SecureX — Zero-Trust Platform ArchitectureThe capstone architecture: an auth server, security gateway, OPA policy engine, secrets service, audit service and self-service portal, and how they fit together.