Production-grade guide to gdpr engineering requirements covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.
GDPR Engineering Requirements define the technical implementation expectations for systems handling personal data of individuals in the European Union. These requirements are triggered when a system must satisfy legal obligations under the General Data Protection Regulation, particularly around data subject rights, data processing transparency, and accountability. They are most critical during system design, compliance audits, and when responding to data subject requests or regulatory inquiries.
To implement the right to access, systems must expose a query endpoint that returns all personal data a controller holds on a specific individual. The endpoint accepts a subject_id and returns a JSON object with the following structure:
{
"subject_id": "usr-7f3a2b1c",
"personal_data": [
{
"field": "email",
"value": "[email protected]",
"source": "user_registration_form",
"updated_at": "2023-06-14T10:23:45Z"
},
{
"field": "name",
"value": "Alice Johnson",
"source": "customer_profile",
"updated_at": "2023-04-05T14:12:30Z"
},
{
"field": "address",
"value": {
"street": "123 Main St",
"city": "Berlin",
"postal_code": "10115",
"country": "DE"
},
"source": "order_history",
"updated_at": "2023-03-20T09:45:11Z"
}
],
"data_processing_activities": [
{
"purpose": "order fulfillment",
"lawful_basis": "contract",
"retention_period": "5 years",
"third_parties": [
"shipping_co_1",
"payment_gateway_3"
],
"consent": {
"given_at": "2023-02-01T11:30:00Z",
"consent_id": "consent-8a1c4b2d"
}
}
],
"last_updated": "2023-06-14T10:23:45Z"
}
The endpoint must respond within 30 seconds for 95% of requests and support pagination via ?page=2&per_page=100. A 404 Not Found is returned if the subject_id is unknown. If the subject has no data, the personal_data array is empty, and data_processing_activities may be omitted.
When a subject requests correction of their data, the system must support a PATCH /api/v1/data-subjects/{subject_id} endpoint. The request body is a JSON object with a changes array:
{
"changes": [
{
"field": "email",
"new_value": "[email protected]",
"reason": "Changed job",
"timestamp": "2023-06-15T08:00:00Z"
},
{
"field": "address",
"new_value": {
"street": "456 Oak Avenue",
"city": "Hamburg",
"postal_code": "20095",
"country": "DE"
},
"reason": "Moved to new apartment",
"timestamp": "2023-06-15T08:00:00Z"
}
]
}
The server must apply changes atomically: if any field fails to update, all changes are rolled back. A 200 OK is returned with a X-Operation-ID header set to a UUID. The response body includes a status field with values pending, applied, or failed, and a details object with applied_fields, failed_fields, and error_messages.
Silent failure occurs when the field path is malformed: address.street is valid, but address.streets is not. The server does not validate nested objects beyond the first level unless explicitly configured. The reason field is optional but must be logged to the audit trail.
To support the right to erase, a system must provide a DELETE /api/v1/data-subjects/{subject_id} endpoint with optional ?permanent=true and ?reason=marketing_opt_out.
A permanent=true request deletes all traces of the subject: data in databases, files, logs, and backups. The endpoint returns 202 Accepted with a Location header pointing to a status check endpoint: GET /api/v1/operations/{operation_id}.
{
"operation_id": "op-3a2b1c4d",
"status": "in_progress",
"progress": 0.45,
"started_at": "2023-06-15T08:10:00Z",
"estimated_completion": "2023-06-15T08:45:00Z"
}
The operation is tracked in a data_operations table with columns: id, subject_id, type, status, started_at, completed_at, progress, details. The details column is a JSON field containing deleted_tables, deleted_files, total_records, and errors.
Failure mode: if the permanent flag is true but a required dependency is down (e.g., S3 is unreachable), the operation fails with status: failed, and details.errors includes s3_connection_timeout, error_code: 504.
Consent is recorded in a consents table with the following schema:
| Column | Type | Constraints |
|---|---|---|
| id | UUID | primary key |
| subject_id | UUID | foreign key |
| purpose | text | not null |
| granted_at | timestamp | not null |
| expires_at | timestamp | nullable |
| source | text | default 'web' |
| metadata | jsonb | nullable |
| revoked_at | timestamp | nullable |
A consent is granted via POST /api/v1/consents with a JSON body:
{
"subject_id": "usr-7f3a2b1c",
"purpose": "marketing_emails",
"expires_at": "2025-06-14T23:59:59Z",
"source": "mobile_app",
"metadata": {
"campaign_id": "summer2023",
"channel": "push_notification"
}
}
The server returns 201 Created with Location header pointing to the new consent’s URI. The metadata field is stored as-is and is queryable via JSON operators.
The expires_at field is critical: if expires_at is not set, the consent is valid indefinitely. A GET /api/v1/consents?subject_id=usr-7f3a2b1c&purpose=marketing_emails returns all consents for that purpose, sorted by granted_at.
Revocation is implemented by setting revoked_at to the current time and returning 200 OK with Content-Type: application/json:
{
"consent_id": "consent-8a1c4b2d",
"revoked_at": "2023-06-15T08:30:00Z",
"status": "revoked",
"inherited_from": "consent-1a2b3c4d"
}
The inherited_from field links to the consent that was the basis for this one, used when a group consent is revoked but individual consent persists.
A background job runs every 15 minutes to check for expired consents. It queries:
SELECT id, subject_id
FROM consents
WHERE expires_at <= NOW() AND revoked_at IS NULL;
For each expired consent, it inserts a record into consent_expirations with consent_id, triggered_at, and reason: 'expiry'.
The register is a collection of data processing activities, each described by a record in the data_processing_activities table with:
| Column | Type | Constraints |
|---|---|---|
| id | UUID | primary key |
| controller_name | text | not null |
| purpose | text | not null |
| legal_basis | text | not null |
| data_retention_period | interval | default '3 years' |
| third_party_ids | text[] | nullable |
| data_category | text[] | not null |
| processing_location | text | default 'EU' |
The register is exposed via GET /api/v1/data-processing-register with optional query parameters:
?purpose=customer_support?legal_basis=contract?location=USThe response is a list of objects:
[
{
"id": "dpa-12345",
"controller_name": "Acme Corp",
"purpose": "customer support",
"legal_basis": "contract",
"data_retention_period": "2 years",
"third_party_ids": ["support_ticketing_system", "ai_chatbot"],
"data_category": ["personal", "contact", "transaction"],
"processing_location": "EU"
}
]
The data_category field allows filtering and is used for reporting and audit purposes. The third_party_ids list is not indexed by default, so queries on it require jsonb or array indexing.
All data operations must be logged to a central audit trail. Each operation emits a record to a audit_logs table:
| Column | Type | Constraints |
|---|---|---|
| id | UUID | primary key |
| operation_type | text | not null |
| subject_id | UUID | nullable |
| details | jsonb | not null |
| occurred_at | timestamp | not null |
| source_system | text | default 'core-api' |
For example, a data access request generates:
{
"operation_type": "data_access",
"subject_id": "usr-7f3a2b1c",
"details": {
"request_id": "req-9a8b7c6d",
"requester_ip": "192.168.1.10",
"requester_user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
},
"occurred_at": "2023-06-14T10:23:45Z",
"source_system": "data-access-service"
}
The details field is used for both structured and arbitrary data, and can be queried using JSON operators.
A critical failure mode: if subject_id is NULL but the operation is tied to a specific user, the audit log is less useful for tracing individual data subject activities. Systems often forget to populate subject_id for batch operations.
DPIA results are stored in a dpias table:
| Column | Type | Constraints |
|---|---|---|
| id | UUID | primary key |
| name | text | not null |
| status | text | not null |
| risk_level | text | default 'medium' |
| assessed_at | timestamp | not null |
| findings | jsonb | nullable |
| action_items | jsonb | not null |
The action_items field contains a list of tasks, each with id, description, assigned_to, due_date, status. The findings field is free-form but includes risk_factors, recommendations, and evidence.
A GET /api/v1/dpias?status=completed&risk_level=high returns all high-risk completed DPIAs.
The system must ensure that any new data processing activity with a risk level of high must have a DPIA. This is enforced by a validation step in the data processing register creation workflow.
To support data portability, systems must provide a GET /api/v1/data-subjects/{subject_id}/export endpoint that returns a ZIP file containing:
personal_data.json file with the full personal data as defined in the Access right.consents.json file listing all consents.data_processing_activities.json file with all relevant processing activities.data_export_metadata.json file with:{
"export_id": "exp-1a2b3c4d",
"exported_at": "2023-06-15T09:00:00Z",
"format": "json",
"version": "1.0",
"source_system": "acme-core",
"data_subject_id": "usr-7f3a2b1c"
}
The ZIP file is served with Content-Type: application/zip and Content-Disposition: attachment; filename="data_export_7f3a2b1c.zip".
The export is asynchronous: a 202 Accepted is returned with Location pointing to a status check endpoint. The ZIP file is stored in object storage (e.g., AWS S3, GCP Cloud Storage) and is available for 30 days.
Silent failure: if the personal_data.json file is not included in the ZIP, the export is considered invalid. The system must validate the ZIP structure before marking the export as complete.
The export must be in a standard format: JSON, XML, or CSV. The default is JSON. When exporting to CSV, each table becomes a separate file. The personal_data table becomes personal_data.csv, and the consents table becomes consents.csv.
The data_processing_activities table is exported as data_processing_activities.csv with columns: id, controller_name, purpose, legal_basis, data_retention_period, third_party_ids, data_category, processing_location.
The data_category and third_party_ids fields are comma-separated in the CSV. The data_category field may contain multiple values: "personal, contact, transaction".
Retention policies are configured in a retention_policies table:
| Column | Type | Constraints |
|---|---|---|
| id | UUID | primary key |
| data_category | text | not null |
| retention_period | interval | not null |
| retention_action | text | default 'archive_and_delete' |
| last_run | timestamp | nullable |
The system runs a daily job that checks retention_policies and processes data older than retention_period. The job queries:
SELECT id, data_category, retention_period, retention_action
FROM retention_policies
WHERE last_run <= NOW() - INTERVAL '1 day';
For each policy, it runs:
WITH expired AS (
SELECT id
FROM personal_data
WHERE created_at <= NOW() - retention_policies.retention_period
AND category = retention_policies.data_category
)
UPDATE retention_policies
SET last_run = NOW()
WHERE id IN (SELECT id FROM expired);
The retention_action field determines the next steps:
archive_and_delete: move data to archive storage and delete from primary storage.archive_only: copy to archive, keep in primary.delete_only: remove from primary, keep in archive.Failure: if archive_and_delete is used but the archive system is down, the job logs an error and continues, but does not update last_run. The next day, the same data is processed again.
All data subject requests are tracked in a data_subject_requests table with:
| Column | Type | Constraints |
|---|---|---|
| id | UUID | primary key |
| subject_id | UUID | not null |
| type | text | not null |
| status | text | default 'received' |
| submitted_at | timestamp | not null |
| completed_at | timestamp | nullable |
| metadata | jsonb | nullable |
The type field includes: access, rectification, erasure, consent, portability, restriction.
The state machine progresses as:
received → processing → completed or failedfailed → retried → completedA PATCH /api/v1/data-subject-requests/{id} endpoint updates the status and metadata:
{
"status": "processing",
"metadata": {
"assigned_to": "support_team",
"priority": "high",
"estimated_time": "2 days"
}
}
The system must send email notifications on status changes. The metadata field is used to store arbitrary request-specific data, such as reason for erasure or new_email for rectification.
The request lifecycle is the backbone of compliance: every audit, every subject inquiry, every regulatory response traces back to this state machine.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy