Add LDAP-backed SCIM server reference implementation

Adds a new scim-server-ldap module providing a reference SCIM server
backed by an LDAP directory (Apache Directory Server / OpenLDAP).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/pom.xml b/pom.xml
index d960a6c..1ec4c87 100644
--- a/pom.xml
+++ b/pom.xml
@@ -69,6 +69,7 @@
     <module>scim-server</module>
     <module>scim-server-examples/scim-server-memory</module>
     <module>scim-server-examples/scim-server-jersey</module>
+    <module>reference-projects/scim-server-ldap</module>
     <module>scim-server-examples/scim-server-jersey-4</module>
     <module>scim-server-examples/scim-server-quarkus</module>
     <module>scim-server-examples/scim-server-spring-boot</module>
diff --git a/reference-projects/scim-server-ldap/README.md b/reference-projects/scim-server-ldap/README.md
new file mode 100644
index 0000000..74de6b8
--- /dev/null
+++ b/reference-projects/scim-server-ldap/README.md
@@ -0,0 +1,191 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# SCIMple LDAP Server
+
+A SCIM 2.0 server backed by an LDAP directory, built on [Apache Directory SCIMple](https://directory.apache.org/scimple/) and the [Apache Directory LDAP API](https://directory.apache.org/api/).
+
+This module implements `Repository<ScimUser>` and `Repository<ScimGroup>` against LDAP, translating SCIM operations into LDAP reads and writes. It is a reference implementation intended to be adapted for production deployments with real LDAP directories.
+
+## Building
+
+Build the full SCIMple project (the LDAP module depends on other SCIMple modules):
+
+```shell
+./mvnw package -DskipTests
+```
+
+This produces an executable uber JAR at `reference-projects/scim-server-ldap/target/scim-server-ldap-1.0.0-SNAPSHOT-exec.jar`.
+
+> **Note:** The uber JAR is built with the [Spring Boot Maven Plugin](https://docs.spring.io/spring-boot/maven-plugin/) for its
+> JAR repackaging capability only — the project has **no runtime dependency on Spring**. The JAR uses Spring Boot's `JarLauncher`
+> as the entry point, which delegates to `LdapApplication.main()`. If you inspect the manifest, that is why you will see Spring
+> Boot references.
+
+## Quick Start
+
+Run the server with an embedded [Apache DS](https://directory.apache.org/apacheds/) instance (no external LDAP required):
+
+```shell
+java -Dldap.embedded=true -jar reference-projects/scim-server-ldap/target/scim-server-ldap-1.0.0-SNAPSHOT-exec.jar
+```
+
+Or, during development, run directly via Maven without building the uber JAR first:
+
+```shell
+./mvnw exec:java -pl reference-projects/scim-server-ldap -Dldap.embedded=true
+```
+
+The embedded mode seeds the directory with sample users and a group. Test it:
+
+```shell
+curl http://localhost:8080/Users
+curl http://localhost:8080/Groups
+curl http://localhost:8080/ServiceProviderConfig
+```
+
+SCIM endpoints expect `Content-Type: application/scim+json` for write operations (POST, PUT, PATCH).
+
+The server port defaults to 8080 and can be changed with `-Dserver.port=9090`.
+
+To connect to an external LDAP server instead, configure `src/main/resources/scim-ldap.yml` and run without the embedded flag.
+
+## Configuration
+
+All configuration lives in a single file: **`scim-ldap.yml`** on the classpath.
+
+```yaml
+ldap:
+  embedded: false  # set to true to start an in-memory ApacheDS (ignores host/port/bind settings)
+  host: localhost
+  port: 389
+  bindDn: cn=admin,dc=example,dc=com
+  bindPassword: secret
+  useTls: false
+  userBaseDn: ou=users,dc=example,dc=com
+  groupBaseDn: ou=groups,dc=example,dc=com
+
+user:
+  objectClasses: [inetOrgPerson, organizationalPerson, person, top]
+  rdnAttribute: uid
+  attributes:
+    userName: uid
+    "name.givenName": givenName
+    "name.familyName": sn
+    displayName: displayName
+    "emails.value": mail
+    # ... full mapping in scim-ldap.yml
+
+group:
+  objectClasses: [groupOfNames, top]
+  rdnAttribute: cn
+  attributes:
+    displayName: cn
+    "members.value": member
+```
+
+### System Property Overrides
+
+LDAP connection settings can be overridden at runtime via system properties, which is useful for containerized deployments where secrets come from the environment:
+
+```shell
+java -Dldap.host=ldap.corp.example.com \
+     -Dldap.port=636 \
+     -Dldap.bind.dn=cn=scim,ou=services,dc=corp \
+     -Dldap.bind.password=$LDAP_PASSWORD \
+     -Dldap.use.tls=true \
+     -jar reference-projects/scim-server-ldap/target/scim-server-ldap-1.0.0-SNAPSHOT-exec.jar
+```
+
+| System Property       | YAML Key            | Default                       |
+|-----------------------|---------------------|-------------------------------|
+| `ldap.embedded`       | `ldap.embedded`     | `false`                       |
+| `ldap.host`           | `ldap.host`         | `localhost`                   |
+| `ldap.port`           | `ldap.port`         | `389`                         |
+| `ldap.bind.dn`        | `ldap.bindDn`       | `cn=admin,dc=example,dc=com`  |
+| `ldap.bind.password`  | `ldap.bindPassword` | `secret`                      |
+| `ldap.use.tls`        | `ldap.useTls`       | `false`                       |
+| `ldap.base.dn.users`  | `ldap.userBaseDn`   | `ou=users,dc=example,dc=com`  |
+| `ldap.base.dn.groups` | `ldap.groupBaseDn`  | `ou=groups,dc=example,dc=com` |
+| `server.port`         | -                   | `8080`                        |
+
+## Architecture
+
+```
+SCIM Request
+    |
+    v
++-------------------+     +------------------+     +---------------------+
+| LdapUserRepository|---->| AttributeMapper  |---->| LdapDao             |
+| LdapGroupRepository     | (SCIM <-> LDAP)  |     | (CRUD operations)   |
++-------------------+     +------------------+     +---------------------+
+         |                        |                          |
+         |                +------------------+     +---------------------+
+         +--------------->| FilterTranslator |     | LdapConnectionManager
+                          | (SCIM -> LDAP)   |     | (connection pool)   |
+                          +------------------+     +---------------------+
+```
+
+### Repository Layer (`service/`)
+
+`LdapUserRepository` and `LdapGroupRepository` implement SCIMple's `Repository<T>` SPI. They handle:
+
+- **CRUD operations** — create, get, update, patch, delete
+- **Search with filtering** — delegates to `FilterTranslator` then executes against LDAP
+- **Identity via entryUUID** — SCIM resource IDs are the LDAP server's `entryUUID` operational attribute (immutable, server-assigned)
+- **Group membership resolution** — bidirectional mapping between member DNs and SCIM entryUUIDs
+
+### Filter Translation (`mapping/FilterTranslator`)
+
+SCIM filter expressions are translated into LDAP filter strings. All SCIM comparison operators are supported:
+
+| SCIM | LDAP | Example |
+|---|---|---|
+| `eq` | `(attr=value)` | `userName eq "jdoe"` -> `(uid=jdoe)` |
+| `co` | `(attr=*value*)` | `displayName co "test"` -> `(displayName=*test*)` |
+| `sw` | `(attr=value*)` | `userName sw "j"` -> `(uid=j*)` |
+| `ew` | `(attr=*value)` | `emails.value ew "@example.com"` -> `(mail=*@example.com)` |
+| `pr` | `(attr=*)` | `title pr` -> `(title=*)` |
+| `gt`, `ge`, `lt`, `le` | Ordering filters | Mapped to LDAP `>=`/`<=` combinations |
+| `and`, `or`, `not` | `(&...)`, `(\|...)`, `(!...)` | Logical composition |
+
+Attribute names are resolved through the configurable SCIM-to-LDAP mapping (e.g., `userName` -> `uid`). Filter values are escaped per RFC 4515.
+
+### Attribute Mapping (`mapping/AttributeMapper`)
+
+Converts between SCIM resources and LDAP entries using the `user` and `group` sections of `scim-ldap.yml`. The mapping is a simple key-value structure:
+
+```yaml
+# SCIM attribute path -> LDAP attribute name
+userName: uid
+"name.givenName": givenName
+"emails.value": mail
+```
+
+To adapt for a different LDAP schema (e.g., Active Directory), change the attribute mapping and objectClasses in `scim-ldap.yml` — no code changes required.
+
+## Testing
+
+The module uses the SCIMple compliance test suite (`scim-compliance-tests`) with an embedded [Apache DS](https://directory.apache.org/apacheds/) server. Run the tests with:
+
+```shell
+./mvnw verify -pl reference-projects/scim-server-ldap
+```
+
+The test configuration in `src/test/resources/scim-ldap.yml` adds `extensibleObject` to the user objectClasses so that custom attributes (`scimActive`, `scimPhoneTypes`) are accepted by the Apache DS schema.
diff --git a/reference-projects/scim-server-ldap/pom.xml b/reference-projects/scim-server-ldap/pom.xml
new file mode 100644
index 0000000..0196ee8
--- /dev/null
+++ b/reference-projects/scim-server-ldap/pom.xml
@@ -0,0 +1,217 @@
+<!--  Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License. -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.apache.directory.scimple</groupId>
+    <artifactId>scimple</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+    <relativePath>../..</relativePath>
+  </parent>
+
+  <artifactId>scim-server-ldap</artifactId>
+  <name>SCIMple - Reference - LDAP Server</name>
+
+  <properties>
+    <module.name>org.apache.directory.scim.ldap</module.name>
+    <version.ldap-api>2.1.7</version.ldap-api>
+    <version.apacheds>2.0.0.AM27</version.apacheds>
+  </properties>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.glassfish.jersey.containers</groupId>
+      <artifactId>jersey-container-grizzly2-http</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.glassfish.jersey.inject</groupId>
+      <artifactId>jersey-hk2</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.glassfish.jersey.ext.cdi</groupId>
+      <artifactId>jersey-weld2-se</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.jboss.weld.se</groupId>
+      <artifactId>weld-se-core</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+    <dependency>
+      <groupId>jakarta.servlet</groupId>
+      <artifactId>jakarta.servlet-api</artifactId>
+      <scope>runtime</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>jakarta.ws.rs</groupId>
+      <artifactId>jakarta.ws.rs-api</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>jakarta.inject</groupId>
+      <artifactId>jakarta.inject-api</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>jakarta.annotation</groupId>
+      <artifactId>jakarta.annotation-api</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>jakarta.enterprise</groupId>
+      <artifactId>jakarta.enterprise.cdi-api</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>jakarta.enterprise</groupId>
+      <artifactId>jakarta.enterprise.lang-model</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.directory.scimple</groupId>
+      <artifactId>scim-server</artifactId>
+    </dependency>
+
+    <!-- Apache Directory LDAP API -->
+    <dependency>
+      <groupId>org.apache.directory.api</groupId>
+      <artifactId>api-all</artifactId>
+      <version>${version.ldap-api}</version>
+    </dependency>
+
+    <!-- Embedded ApacheDS (used when ldap.embedded=true) -->
+    <dependency>
+      <groupId>org.apache.directory.server</groupId>
+      <artifactId>apacheds-core-annotations</artifactId>
+      <version>${version.apacheds}</version>
+      <exclusions>
+        <exclusion>
+          <groupId>org.apache.directory.api</groupId>
+          <artifactId>api-ldap-schema-data</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.directory.server</groupId>
+      <artifactId>apacheds-protocol-ldap</artifactId>
+      <version>${version.apacheds}</version>
+      <exclusions>
+        <exclusion>
+          <groupId>org.apache.directory.api</groupId>
+          <artifactId>api-ldap-schema-data</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+
+    <!-- YAML configuration support -->
+    <dependency>
+      <groupId>com.fasterxml.jackson.dataformat</groupId>
+      <artifactId>jackson-dataformat-yaml</artifactId>
+    </dependency>
+
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>jul-to-slf4j</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>ch.qos.logback</groupId>
+      <artifactId>logback-classic</artifactId>
+    </dependency>
+
+    <!-- Test dependencies -->
+    <dependency>
+      <groupId>org.apache.directory.scimple</groupId>
+      <artifactId>scim-compliance-tests</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.directory.server</groupId>
+      <artifactId>apacheds-server-integ</artifactId>
+      <version>${version.apacheds}</version>
+      <scope>test</scope>
+      <exclusions>
+        <!-- Exclude to avoid duplicate schema LDIF resources with api-all -->
+        <exclusion>
+          <groupId>org.apache.directory.api</groupId>
+          <artifactId>api-ldap-schema-data</artifactId>
+        </exclusion>
+      </exclusions>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-junit-jupiter</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.codehaus.mojo</groupId>
+        <artifactId>exec-maven-plugin</artifactId>
+        <configuration>
+          <mainClass>org.apache.directory.scim.ldap.LdapApplication</mainClass>
+        </configuration>
+      </plugin>
+      <!-- Repackages the module JAR as an executable uber JAR.
+           This plugin is used solely for packaging — the project has no Spring dependency. -->
+      <plugin>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-maven-plugin</artifactId>
+        <version>${version.spring-boot4}</version>
+        <executions>
+          <execution>
+            <goals>
+              <goal>repackage</goal>
+            </goals>
+            <configuration>
+              <mainClass>org.apache.directory.scim.ldap.LdapApplication</mainClass>
+              <classifier>exec</classifier>
+              <!-- Weld SE cannot discover bean archives inside Spring Boot's nested JAR layout
+                   (BOOT-INF/lib/). JARs listed here are extracted to a temp directory at runtime
+                   so Weld's FileSystemBeanArchiveHandler can scan their beans.xml. -->
+              <requiresUnpack>
+                <dependency>
+                  <groupId>org.glassfish.jersey.ext.cdi</groupId>
+                  <artifactId>jersey-weld2-se</artifactId>
+                </dependency>
+                <dependency>
+                  <groupId>org.apache.directory.scimple</groupId>
+                  <artifactId>scim-core</artifactId>
+                </dependency>
+                <dependency>
+                  <groupId>org.apache.directory.scimple</groupId>
+                  <artifactId>scim-server</artifactId>
+                </dependency>
+              </requiresUnpack>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+
+</project>
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/LdapApplication.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/LdapApplication.java
new file mode 100644
index 0000000..b48dcb7
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/LdapApplication.java
@@ -0,0 +1,151 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.enterprise.inject.se.SeContainer;
+import jakarta.enterprise.inject.se.SeContainerInitializer;
+import jakarta.ws.rs.SeBootstrap;
+import jakarta.ws.rs.core.UriBuilder;
+import org.apache.directory.scim.ldap.ldap.LdapConnectionManager;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.ldap.mapping.FilterTranslator;
+import org.apache.directory.scim.ldap.service.LdapGroupRepository;
+import org.apache.directory.scim.ldap.service.LdapUserRepository;
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
+
+import static org.apache.directory.scim.spec.schema.ServiceProviderConfiguration.AuthenticationSchema.oauthBearer;
+
+import java.net.URI;
+import java.util.Set;
+
+import jakarta.ws.rs.core.Application;
+import org.apache.directory.scim.server.rest.ScimResourceHelper;
+import org.slf4j.LoggerFactory;
+import org.slf4j.bridge.SLF4JBridgeHandler;
+
+/**
+ * JAX-RS {@link Application} that bootstraps a standalone SCIM server backed by LDAP.
+ *
+ * <p>This application uses Jersey 3 as the JAX-RS runtime, Weld SE for CDI dependency injection,
+ * and {@link SeBootstrap} to start an embedded HTTP server. It registers all SCIMple JAX-RS
+ * resource and feature classes and produces a {@link ServerConfiguration} bean for the SCIM
+ * service provider.</p>
+ *
+ * @see ScimResourceHelper#scimpleFeatureAndResourceClasses()
+ * @see ServerConfiguration
+ */
+// @ApplicationPath("v2")
+// Embedded Jersey + Grizzly ignores the ApplicationPath annotation
+// https://github.com/eclipse-ee4j/jersey/issues/3222
+@ApplicationScoped
+public class LdapApplication extends Application {
+
+  private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(LdapApplication.class);
+
+  /**
+   * Returns the set of JAX-RS resource and feature classes required by the SCIMple server.
+   *
+   * <p>Delegates to {@link ScimResourceHelper#scimpleFeatureAndResourceClasses()} to register
+   * all SCIM endpoint resources, exception mappers, and JAX-RS features.</p>
+   *
+   * @return a set of JAX-RS classes to register with the runtime
+   */
+  @Override
+  public Set<Class<?>> getClasses() {
+    return ScimResourceHelper.scimpleFeatureAndResourceClasses();
+  }
+
+  /**
+   * CDI producer method that provides the {@link ServerConfiguration} for the SCIM service.
+   *
+   * <p>Configures the server with an identifier and documentation URI pointing to the
+   * Apache Directory SCIMple project.</p>
+   *
+   * @return the SCIM server configuration bean
+   */
+  @Produces
+  ServerConfiguration serverConfiguration() {
+    // TODO: SCIMple should auto-detect filter support from the Repository implementation
+    // rather than requiring manual configuration in ServerConfiguration. This would improve
+    // the developer experience across all SCIMple-based servers.
+    return new ServerConfiguration()
+      .setId("scimple-ldap-example")
+      .setDocumentationUri("https://github.com/apache/directory-scimple")
+      .setSupportsFilter(true)
+      // This does not enforce authentication. Use oauthBearer() or httpBasic() as appropriate.
+      .addAuthenticationSchema(oauthBearer());
+  }
+
+  /**
+   * Starts the standalone SCIM server.
+   *
+   * <p>The server port defaults to 8080 and can be overridden via the {@code server.port}
+   * system property (e.g., {@code -Dserver.port=9090}).</p>
+   *
+   * <p>Initializes a Weld SE CDI container, starts a JAX-RS {@link SeBootstrap} instance,
+   * and blocks the main thread until interrupted (e.g., via {@code CTRL+C}). JUL logging
+   * is bridged to SLF4J before startup.</p>
+   *
+   * <p>Usage: {@code java -jar scim-server-ldap.jar}</p>
+   *
+   * @param args command-line arguments (currently unused)
+   */
+  public static void main(String[] args) {
+
+    // configure JUL logging
+    SLF4JBridgeHandler.install();
+
+    try {
+
+      // Register CDI beans explicitly instead of using addPackages() — Spring Boot's nested
+      // JAR classloader uses URLs that Weld SE's package scanner cannot enumerate.
+      SeContainer container = SeContainerInitializer.newInstance()
+        .addBeanClasses(
+          LdapApplication.class,
+          ScimLdapConfig.class,
+          LdapConnectionManager.class,
+          LdapDao.class,
+          AttributeMapper.class,
+          FilterTranslator.class,
+          LdapUserRepository.class,
+          LdapGroupRepository.class
+        )
+        .initialize();
+
+      int port = Integer.parseInt(System.getProperty("server.port", "8080"));
+      LdapApplication app = new LdapApplication();
+      SeBootstrap.start(app, SeBootstrap.Configuration.builder().port(port).build())
+        .thenAccept(instance -> instance.stopOnShutdown(stopResult -> container.close()));
+      URI uri = UriBuilder.fromUri("http://localhost/").port(port).build();
+
+      System.out.printf("Application started: %s%nStop the application using CTRL+C%n", uri.toString());
+
+      // block and wait shut down signal, like CTRL+C
+      Thread.currentThread().join();
+
+    } catch (InterruptedException ex) {
+      LOG.error("Service Interrupted", ex);
+    }
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServer.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServer.java
new file mode 100644
index 0000000..5c69dbd
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServer.java
@@ -0,0 +1,255 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.schema.SchemaManager;
+import org.apache.directory.server.core.api.DirectoryService;
+import org.apache.directory.server.core.factory.DefaultDirectoryServiceFactory;
+import org.apache.directory.server.core.partition.impl.avl.AvlPartition;
+import org.apache.directory.server.ldap.LdapServer;
+import org.apache.directory.server.protocol.shared.transport.TcpTransport;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.channels.ServerSocketChannel;
+
+/**
+ * Starts an embedded Apache Directory Server for development and demonstration purposes.
+ *
+ * <p>This is a plain helper class (not a CDI bean) instantiated by {@link LdapConnectionManager}
+ * when {@link ScimLdapConfig#isEmbedded()} is {@code true}. It creates an in-memory LDAP
+ * directory with the base OUs needed for SCIM user and group operations, and seeds it with
+ * sample users and a group so the server returns useful data immediately.</p>
+ *
+ * <p>The embedded server always listens on a random available port on {@code 127.0.0.1}.
+ * After calling {@link #start(ScimLdapConfig)}, use {@link #getHost()} and
+ * {@link #getPort()} to obtain the connection details.</p>
+ *
+ * @see ScimLdapConfig#isEmbedded()
+ */
+public class EmbeddedLdapServer {
+
+  private static final Logger LOG = LoggerFactory.getLogger(EmbeddedLdapServer.class);
+  private DirectoryService directoryService;
+  private LdapServer ldapServer;
+  private String host;
+  private int port;
+
+  /**
+   * Starts the embedded Apache DS instance and seeds the base directory structure.
+   *
+   * <p>Creates an in-memory directory with a partition rooted at the base DN derived from
+   * {@link ScimLdapConfig#getUserBaseDn()}, then seeds it with organizational units, sample
+   * users, and a group. The server binds to {@code 127.0.0.1} on a randomly selected
+   * available port.</p>
+   *
+   * @param config the SCIM-LDAP configuration providing base DNs for users and groups
+   * @throws Exception if the directory service or LDAP server fails to start
+   */
+  public void start(ScimLdapConfig config) throws Exception {
+    // Initialize directory service
+    DefaultDirectoryServiceFactory factory = new DefaultDirectoryServiceFactory();
+    factory.init("scimple-embedded");
+    directoryService = factory.getDirectoryService();
+
+    // Derive the base DN from the user base DN (e.g. ou=users,dc=example,dc=com -> dc=example,dc=com)
+    String userBaseDn = config.getUserBaseDn();
+    String baseDn = userBaseDn.substring(userBaseDn.indexOf(',') + 1).trim();
+
+    // Add partition for the base DN
+    AvlPartition partition = new AvlPartition(directoryService.getSchemaManager());
+    partition.setId("scimple");
+    partition.setSuffixDn(new Dn(baseDn));
+    directoryService.addPartition(partition);
+
+    // Always bind to an ephemeral port — the config port is for external LDAP connections
+    try (ServerSocketChannel ch = ServerSocketChannel.open()) {
+      ch.bind(new InetSocketAddress(0));
+      port = ch.socket().getLocalPort();
+    }
+    host = InetAddress.getLoopbackAddress().getHostAddress();
+
+    // Start LDAP protocol server
+    ldapServer = new LdapServer();
+    ldapServer.setDirectoryService(directoryService);
+    ldapServer.setTransports(new TcpTransport(host, port));
+    ldapServer.start();
+
+    // Seed base directory structure and sample data
+    seedBaseEntries(config);
+    seedSampleData(config);
+
+    LOG.info("Embedded ApacheDS started on {}:{}", host, port);
+  }
+
+  /**
+   * Seeds the base directory structure: domain entry, user OU, and group OU.
+   *
+   * <p>Subclasses may override to customize the base directory structure. The default
+   * implementation creates the domain entry and organizational units derived from
+   * {@link ScimLdapConfig#getUserBaseDn()} and {@link ScimLdapConfig#getGroupBaseDn()}.</p>
+   *
+   * @param config the SCIM-LDAP configuration providing base DNs
+   * @throws Exception if entries cannot be created
+   */
+  protected void seedBaseEntries(ScimLdapConfig config)
+    throws Exception {
+    SchemaManager schemaManager = directoryService.getSchemaManager();
+    String userBaseDn = config.getUserBaseDn();
+    String baseDn = userBaseDn.substring(userBaseDn.indexOf(',') + 1).trim();
+    var session = directoryService.getAdminSession();
+
+    // Extract the domain component for the base entry (e.g. dc=example from dc=example,dc=com)
+    String dcValue = baseDn.split(",")[0].split("=")[1];
+
+    // Create base domain entry
+    session.add(new DefaultEntry(schemaManager,
+      baseDn,
+      "objectClass: domain",
+      "dc: " + dcValue));
+
+    // Create user OU
+    String userOu = userBaseDn.split(",")[0].split("=")[1];
+    session.add(new DefaultEntry(schemaManager,
+      userBaseDn,
+      "objectClass: organizationalUnit",
+      "ou: " + userOu));
+
+    // Create group OU
+    String groupBaseDn = config.getGroupBaseDn();
+    String groupOu = groupBaseDn.split(",")[0].split("=")[1];
+    session.add(new DefaultEntry(schemaManager,
+      groupBaseDn,
+      "objectClass: organizationalUnit",
+      "ou: " + groupOu));
+
+    LOG.debug("Seeded base entries: {}, {}, {}", baseDn, userBaseDn, groupBaseDn);
+  }
+
+  /**
+   * Seeds sample data into the directory for demonstration purposes.
+   *
+   * <p>Subclasses may override to provide custom seed data (e.g. test-specific entries)
+   * or to no-op if no sample data is desired.</p>
+   *
+   * @param config the SCIM-LDAP configuration providing base DNs
+   * @throws Exception if entries cannot be created
+   */
+  protected void seedSampleData(ScimLdapConfig config)
+    throws Exception {
+    SchemaManager schemaManager = directoryService.getSchemaManager();
+    var session = directoryService.getAdminSession();
+    String userBaseDn = config.getUserBaseDn();
+    String groupBaseDn = config.getGroupBaseDn();
+
+    // Sample users
+    session.add(new DefaultEntry(schemaManager,
+      "uid=bjensen," + userBaseDn,
+      "objectClass: inetOrgPerson",
+      "objectClass: organizationalPerson",
+      "objectClass: person",
+      "objectClass: top",
+      "uid: bjensen",
+      "cn: Barbara Jensen",
+      "sn: Jensen",
+      "givenName: Barbara",
+      "displayName: Barbara Jensen",
+      "mail: bjensen@example.com",
+      "title: Vice President"));
+
+    session.add(new DefaultEntry(schemaManager,
+      "uid=jsmith," + userBaseDn,
+      "objectClass: inetOrgPerson",
+      "objectClass: organizationalPerson",
+      "objectClass: person",
+      "objectClass: top",
+      "uid: jsmith",
+      "cn: John Smith",
+      "sn: Smith",
+      "givenName: John",
+      "displayName: John Smith",
+      "mail: jsmith@example.com",
+      "title: Engineer"));
+
+    session.add(new DefaultEntry(schemaManager,
+      "uid=awhite," + userBaseDn,
+      "objectClass: inetOrgPerson",
+      "objectClass: organizationalPerson",
+      "objectClass: person",
+      "objectClass: top",
+      "uid: awhite",
+      "cn: Alice White",
+      "sn: White",
+      "givenName: Alice",
+      "displayName: Alice White",
+      "mail: awhite@example.com",
+      "title: Manager"));
+
+    // Sample group with all users as members
+    session.add(new DefaultEntry(schemaManager,
+      "cn=Engineering," + groupBaseDn,
+      "objectClass: groupOfNames",
+      "objectClass: top",
+      "cn: Engineering",
+      "member: uid=bjensen," + userBaseDn,
+      "member: uid=jsmith," + userBaseDn,
+      "member: uid=awhite," + userBaseDn));
+
+    LOG.debug("Seeded sample users and groups");
+  }
+
+  /**
+   * Stops the embedded LDAP server and directory service.
+   *
+   * <p>Stops components in reverse order: LDAP protocol server first, then the
+   * underlying directory service. Safe to call even if the server was never started.</p>
+   */
+  public void stop() {
+    if (ldapServer != null) {
+      ldapServer.stop();
+    }
+    if (directoryService != null) {
+      try {
+        directoryService.shutdown();
+      } catch (Exception e) {
+        LOG.warn("Failed to shut down embedded directory service", e);
+      }
+    }
+    LOG.info("Embedded ApacheDS stopped");
+  }
+
+  /**
+   * Returns the underlying directory service, for subclasses that need to register custom
+   * schema attributes or add entries outside the standard seed methods.
+   *
+   * @return the directory service, or {@code null} if the server has not been started
+   */
+  protected DirectoryService getDirectoryService() { return directoryService; }
+
+  /** Returns the host the embedded server is listening on. */
+  public String getHost() { return host; }
+
+  /** Returns the port the embedded server is listening on. */
+  public int getPort() { return port; }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapConnectionManager.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapConnectionManager.java
new file mode 100644
index 0000000..2f29048
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapConnectionManager.java
@@ -0,0 +1,144 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.apache.directory.ldap.client.api.LdapConnectionConfig;
+import org.apache.directory.ldap.client.api.LdapConnectionPool;
+import org.apache.directory.ldap.client.api.DefaultPoolableLdapConnectionFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages a pool of authenticated LDAP connections using the Apache Directory LDAP API.
+ *
+ * <p>This CDI bean is application-scoped: a single {@link LdapConnectionPool} is created at
+ * startup from {@link ScimLdapConfig} and shared across all request threads. When
+ * {@link ScimLdapConfig#isEmbedded()} is {@code true}, an {@link EmbeddedLdapServer} is
+ * started automatically before creating the connection pool.</p>
+ *
+ * <p>Callers obtain a connection with {@link #getConnection()} and must return it via
+ * {@link #releaseConnection(LdapConnection)} in a {@code finally} block.
+ */
+@ApplicationScoped
+public class LdapConnectionManager {
+
+  private static final Logger LOG = LoggerFactory.getLogger(LdapConnectionManager.class);
+
+  private LdapConnectionPool pool;
+  private EmbeddedLdapServer embeddedServer;
+
+  @Inject
+  ScimLdapConfig properties;
+
+  protected LdapConnectionManager() {}
+
+  @PostConstruct
+  void init() {
+    String host = properties.getHost();
+    int port = properties.getPort();
+    String bindDn = properties.getBindDn();
+
+    // Start embedded ApacheDS if configured
+    if (properties.isEmbedded()) {
+      try {
+        embeddedServer = new EmbeddedLdapServer();
+        embeddedServer.start(properties);
+        host = embeddedServer.getHost();
+        port = embeddedServer.getPort();
+        bindDn = "uid=admin,ou=system";
+      } catch (Exception e) {
+        throw new IllegalStateException("Failed to start embedded LDAP server", e);
+      }
+    }
+
+    try {
+      LdapConnectionConfig config = new LdapConnectionConfig();
+      config.setLdapHost(host);
+      config.setLdapPort(port);
+      config.setName(bindDn);
+      config.setCredentials(properties.isEmbedded() ? "secret" : properties.getBindPassword());
+      // When TLS is enabled, the connection uses the JVM's default trust store (javax.net.ssl.trustStore).
+      // Custom trust store configuration is not yet supported — configure via JVM system properties if needed.
+      if (properties.isUseTls()) {
+        config.setUseTls(true);
+      }
+
+      DefaultPoolableLdapConnectionFactory factory =
+        new DefaultPoolableLdapConnectionFactory(config);
+      pool = new LdapConnectionPool(factory);
+      LOG.info("LDAP connection pool initialized for {}:{} as {}", host, port, bindDn);
+    } catch (Exception e) {
+      throw new IllegalStateException(
+        "Failed to initialize LDAP connection pool for " + host + ":" + port + " as " + bindDn, e);
+    }
+  }
+
+  /**
+   * Borrows an authenticated {@link LdapConnection} from the pool.
+   *
+   * <p>The caller is responsible for returning the connection via
+   * {@link #releaseConnection(LdapConnection)} when finished.
+   *
+   * @return a pooled LDAP connection ready for use
+   * @throws LdapException if a connection cannot be obtained from the pool
+   */
+  public LdapConnection getConnection() throws LdapException {
+    return pool.getConnection();
+  }
+
+  /**
+   * Returns a previously borrowed {@link LdapConnection} to the pool.
+   *
+   * <p>If the connection is {@code null}, this method is a no-op. Any exception during
+   * release is logged at WARN level and swallowed so that it does not mask the original
+   * operation's result.
+   *
+   * @param connection the connection to return, or {@code null}
+   */
+  public void releaseConnection(LdapConnection connection) {
+    if (connection != null) {
+      try {
+        pool.releaseConnection(connection);
+      } catch (LdapException e) {
+        LOG.warn("Failed to release LDAP connection", e);
+      }
+    }
+  }
+
+  @PreDestroy
+  void close() {
+    if (pool != null) {
+      try {
+        pool.close();
+      } catch (Exception e) {
+        LOG.warn("Failed to close LDAP connection pool", e);
+      }
+    }
+    if (embeddedServer != null) {
+      embeddedServer.stop();
+    }
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapDao.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapDao.java
new file mode 100644
index 0000000..33899b1
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapDao.java
@@ -0,0 +1,424 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.apache.directory.api.ldap.model.cursor.SearchCursor;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.api.ldap.model.exception.LdapEntryAlreadyExistsException;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapNoSuchObjectException;
+import org.apache.directory.api.ldap.model.filter.EqualityNode;
+import org.apache.directory.api.ldap.model.filter.ExprNode;
+import org.apache.directory.api.ldap.model.message.SearchRequest;
+import org.apache.directory.api.ldap.model.message.SearchRequestImpl;
+import org.apache.directory.api.ldap.model.message.SearchScope;
+import org.apache.directory.api.ldap.model.message.controls.PagedResults;
+import org.apache.directory.api.ldap.model.message.controls.PagedResultsImpl;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.apache.directory.scim.ldap.mapping.FilterTranslator;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.exception.ConflictResourceException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Data access object that performs LDAP CRUD operations on behalf of the SCIM resource providers.
+ *
+ * <p>Each public method obtains an {@link LdapConnection} from the
+ * {@link LdapConnectionManager}, executes the operation, and ensures the connection is
+ * released in a {@code finally} block. LDAP-specific exceptions are mapped to the
+ * appropriate {@link ResourceException} subclass (e.g.
+ * {@link ResourceNotFoundException}, {@link ConflictResourceException}).</p>
+ *
+ * <p>Search operations use the LDAP Simple Paged Results Control (RFC 2696) to
+ * iterate through large result sets in server-sized pages, rather than loading all
+ * matching entries into memory at once. SCIM pagination parameters ({@code startIndex}
+ * and {@code count}) are applied during iteration.</p>
+ */
+@ApplicationScoped
+public class LdapDao {
+
+  private static final Logger LOG = LoggerFactory.getLogger(LdapDao.class);
+
+  /** Default page size for the LDAP Simple Paged Results Control. */
+  private static final int LDAP_PAGE_SIZE = 100;
+
+  @Inject
+  LdapConnectionManager connectionManager;
+
+  @Inject
+  FilterTranslator filterTranslator;
+
+  @Inject
+  ScimLdapConfig config;
+
+  protected LdapDao() {}
+
+  /**
+   * Adds a new entry to the LDAP directory.
+   *
+   * @param entry the LDAP entry to create
+   * @throws ConflictResourceException if an entry with the same DN already exists
+   * @throws ResourceException         if the LDAP operation fails for any other reason
+   */
+  public void create(Entry entry) throws ResourceException {
+    LdapConnection conn = null;
+    try {
+      conn = connectionManager.getConnection();
+      conn.add(entry);
+    } catch (LdapEntryAlreadyExistsException e) {
+      throw new ConflictResourceException("Entry already exists: " + entry.getDn(), e);
+    } catch (LdapException e) {
+      throw mapException(e);
+    } finally {
+      connectionManager.releaseConnection(conn);
+    }
+  }
+
+  /**
+   * Looks up an LDAP entry by its distinguished name, requesting all user and operational attributes.
+   *
+   * @param dn the distinguished name of the entry to retrieve
+   * @return the matching LDAP entry (never {@code null})
+   * @throws ResourceNotFoundException if no entry exists at the given DN
+   * @throws ResourceException         if the LDAP operation fails
+   */
+  public Entry lookup(String dn) throws ResourceException {
+    LdapConnection conn = null;
+    try {
+      conn = connectionManager.getConnection();
+      Entry entry = conn.lookup(dn, "*", "+");
+      if (entry == null) {
+        throw new ResourceNotFoundException(dn);
+      }
+      return entry;
+    } catch (ResourceNotFoundException e) {
+      throw e;
+    } catch (LdapNoSuchObjectException e) {
+      throw new ResourceNotFoundException(dn, e);
+    } catch (LdapException e) {
+      throw mapException(e);
+    } finally {
+      connectionManager.releaseConnection(conn);
+    }
+  }
+
+  /**
+   * Searches for users matching a SCIM filter with pagination support.
+   *
+   * <p>Uses the LDAP Simple Paged Results Control (RFC 2696) to iterate through
+   * results in server-sized pages. SCIM pagination is applied during iteration:
+   * entries before {@code startIndex} are skipped without mapping, and iteration
+   * stops after {@code count} entries are collected.</p>
+   *
+   * @param scimFilter  the SCIM filter, may be {@code null} for all users
+   * @param pageRequest the SCIM pagination parameters, may be {@code null}
+   * @return a {@link FilterResponse} with the requested page and correct {@code totalResults}
+   * @throws ResourceException if the LDAP search fails
+   */
+  public FilterResponse<Entry> findUsers(Filter scimFilter, PageRequest pageRequest) throws ResourceException {
+    ExprNode filter = filterTranslator.buildUserSearchFilter(scimFilter);
+    return pagedSearch(config.getUserBaseDn(), filter, pageRequest);
+  }
+
+  /**
+   * Searches for groups matching a SCIM filter with pagination support.
+   *
+   * @param scimFilter  the SCIM filter, may be {@code null} for all groups
+   * @param pageRequest the SCIM pagination parameters, may be {@code null}
+   * @return a {@link FilterResponse} with the requested page and correct {@code totalResults}
+   * @throws ResourceException if the LDAP search fails
+   */
+  public FilterResponse<Entry> findGroups(Filter scimFilter, PageRequest pageRequest) throws ResourceException {
+    ExprNode filter = filterTranslator.buildGroupSearchFilter(scimFilter);
+    return pagedSearch(config.getGroupBaseDn(), filter, pageRequest);
+  }
+
+  /**
+   * Searches for a single LDAP entry matching an exact attribute value under the given base DN.
+   *
+   * @param baseDn    the base DN from which to search
+   * @param attrName  the LDAP attribute name to match
+   * @param attrValue the attribute value to match
+   * @return the first matching entry, or {@code null} if no match is found
+   * @throws ResourceException if the LDAP search fails
+   */
+  public Entry searchByAttribute(String baseDn, String attrName, String attrValue) throws ResourceException {
+    ExprNode filter = new EqualityNode<>(attrName, attrValue);
+    List<Entry> results = searchAll(baseDn, filter);
+    if (results.isEmpty()) {
+      return null;
+    }
+    return results.get(0);
+  }
+
+  /**
+   * Applies one or more modifications to an existing LDAP entry.
+   *
+   * @param dn            the distinguished name of the entry to modify
+   * @param modifications the modifications to apply
+   * @throws ResourceNotFoundException if no entry exists at the given DN
+   * @throws ResourceException         if the LDAP operation fails
+   */
+  public void modify(String dn, Modification... modifications) throws ResourceException {
+    LdapConnection conn = null;
+    try {
+      conn = connectionManager.getConnection();
+      conn.modify(dn, modifications);
+    } catch (LdapNoSuchObjectException e) {
+      throw new ResourceNotFoundException(dn, e);
+    } catch (LdapException e) {
+      throw mapException(e);
+    } finally {
+      connectionManager.releaseConnection(conn);
+    }
+  }
+
+  /**
+   * Renames an LDAP entry by replacing its RDN.
+   *
+   * <p>Uses {@code deleteOldRdn=true} so the old RDN attribute value is removed
+   * from the entry after the rename, keeping the entry consistent with its new DN.</p>
+   *
+   * <p><strong>Note:</strong> LDAP does not support multi-operation transactions
+   * (RFC 4511). Callers that rename and then modify in sequence accept the risk
+   * that a modify failure after a successful rename leaves the entry at the new
+   * DN with its attributes only partially updated.</p>
+   *
+   * @param dn     the current DN of the entry
+   * @param newRdn the new RDN string, e.g. {@code "uid=jsmith"}
+   * @throws ResourceNotFoundException  if no entry exists at {@code dn}
+   * @throws ConflictResourceException  if an entry already exists at the new DN
+   * @throws ResourceException          if the LDAP operation fails
+   */
+  public void rename(String dn, String newRdn) throws ResourceException {
+    LdapConnection conn = null;
+    try {
+      conn = connectionManager.getConnection();
+      conn.rename(dn, newRdn, true);   // deleteOldRdn = true
+    } catch (LdapEntryAlreadyExistsException e) {
+      throw new ConflictResourceException("Entry already exists: " + newRdn, e);
+    } catch (LdapNoSuchObjectException e) {
+      throw new ResourceNotFoundException(dn, e);
+    } catch (LdapException e) {
+      throw mapException(e);
+    } finally {
+      connectionManager.releaseConnection(conn);
+    }
+  }
+
+  /**
+   * Deletes an LDAP entry by its distinguished name.
+   *
+   * @param dn the distinguished name of the entry to delete
+   * @throws ResourceNotFoundException if no entry exists at the given DN
+   * @throws ResourceException         if the LDAP operation fails
+   */
+  public void delete(String dn) throws ResourceException {
+    LdapConnection conn = null;
+    try {
+      conn = connectionManager.getConnection();
+      conn.delete(dn);
+    } catch (LdapNoSuchObjectException e) {
+      throw new ResourceNotFoundException(dn, e);
+    } catch (LdapException e) {
+      throw mapException(e);
+    } finally {
+      connectionManager.releaseConnection(conn);
+    }
+  }
+
+  /**
+   * Executes a paged LDAP search using the Simple Paged Results Control (RFC 2696).
+   *
+   * <p>This method handles SCIM-to-LDAP pagination translation:</p>
+   * <ul>
+   *   <li>Iterates through LDAP result pages of {@value #LDAP_PAGE_SIZE} entries each</li>
+   *   <li>Counts all matching entries to produce an accurate {@code totalResults}</li>
+   *   <li>Skips entries before the SCIM {@code startIndex} without processing them</li>
+   *   <li>Collects up to {@code count} entries for the requested page</li>
+   *   <li>Continues iterating after the page is full to get the correct total count</li>
+   * </ul>
+   *
+   * <p>The LDAP paged results control is forward-only (cookie-based), so random
+   * access to arbitrary pages requires iterating from the beginning. For large
+   * directories, consider LDAP-side filtering to reduce the result set.</p>
+   *
+   * @param baseDn      the base DN to search under
+   * @param filter      the LDAP filter (ExprNode)
+   * @param pageRequest SCIM pagination parameters (1-based startIndex + count), may be {@code null}
+   * @return a {@link FilterResponse} containing the requested page of entries and the total count
+   */
+  private FilterResponse<Entry> pagedSearch(String baseDn, ExprNode filter, PageRequest pageRequest)
+    throws ResourceException {
+
+    // --- SCIM Pagination (RFC 7644 §3.4.2.4) ---
+    //
+    // SCIM uses 1-based indexing:
+    //   startIndex=1 means "start from the first result"
+    //   startIndex=11, count=10 means "return results 11-20"
+    //
+    // We convert to 0-based skip/limit for iteration.
+    // totalResults MUST be the total number of matching entries, not the page size.
+    long skip = 0;
+    long limit = Long.MAX_VALUE;
+
+    if (pageRequest != null) {
+      if (pageRequest.getStartIndex() != null) {
+        // Convert SCIM 1-based startIndex to 0-based skip count
+        skip = Math.max(0, pageRequest.getStartIndex() - 1L);
+      }
+      if (pageRequest.getCount() != null) {
+        limit = pageRequest.getCount();
+      }
+    }
+
+    // --- LDAP Simple Paged Results Control (RFC 2696) ---
+    //
+    // Instead of fetching all matching entries at once (which can overwhelm memory
+    // for large directories), we request entries in pages of LDAP_PAGE_SIZE.
+    //
+    // The control uses a cookie-based iteration model:
+    //   1. First request: send PagedResultsControl with size=LDAP_PAGE_SIZE, empty cookie
+    //   2. Server returns up to LDAP_PAGE_SIZE entries + a cookie
+    //   3. Next request: send the cookie back to get the next page
+    //   4. Repeat until the server returns an empty cookie (no more results)
+    //
+    // We iterate through ALL pages to get an accurate totalResults count,
+    // but only collect entries that fall within the requested SCIM page.
+
+    List<Entry> pageEntries = new ArrayList<>();
+    int totalResults = 0;
+
+    LdapConnection conn = null;
+    try {
+      conn = connectionManager.getConnection();
+      byte[] cookie = null;
+
+      do {
+        SearchRequest request = new SearchRequestImpl();
+        request.setBase(new Dn(baseDn));
+        request.setScope(SearchScope.SUBTREE);
+        request.setFilter(filter);
+        request.addAttributes("*", "+");
+
+        // Add the paged results control with the current cookie
+        PagedResults pagedControl = new PagedResultsImpl();
+        pagedControl.setSize(LDAP_PAGE_SIZE);
+        if (cookie != null) {
+          pagedControl.setCookie(cookie);
+        }
+        request.addControl(pagedControl);
+
+        try (SearchCursor cursor = conn.search(request)) {
+          while (cursor.next()) {
+            if (cursor.isEntry()) {
+              // Count every matching entry for totalResults
+              totalResults++;
+
+              // Collect entries that fall within the requested SCIM page:
+              //   - Skip the first 'skip' entries (before startIndex)
+              //   - Collect up to 'limit' entries (the page)
+              //   - Continue counting after the page for totalResults
+              if (totalResults > skip && pageEntries.size() < limit) {
+                pageEntries.add(cursor.getEntry());
+              }
+            }
+          }
+
+          // Extract the cookie from the response for the next page
+          // An empty cookie (length 0) means no more results
+          if (cursor.getSearchResultDone() != null) {
+            PagedResults responseControl = (PagedResults) cursor.getSearchResultDone()
+              .getControl(PagedResults.OID);
+            if (responseControl != null && responseControl.getCookie() != null
+              && responseControl.getCookie().length > 0) {
+              cookie = responseControl.getCookie();
+            } else {
+              cookie = null; // No more pages
+            }
+          } else {
+            cookie = null;
+          }
+        }
+      } while (cookie != null);
+
+      return new FilterResponse<>(pageEntries, totalResults);
+    } catch (LdapException e) {
+      throw mapException(e);
+    } catch (Exception e) {
+      throw mapException(e);
+    } finally {
+      connectionManager.releaseConnection(conn);
+    }
+  }
+
+  /**
+   * Searches for all entries matching the filter (no pagination). Used internally
+   * for simple lookups like {@link #searchByAttribute}.
+   */
+  private List<Entry> searchAll(String baseDn, ExprNode filter) throws ResourceException {
+    LdapConnection conn = null;
+    try {
+      conn = connectionManager.getConnection();
+
+      SearchRequest request = new SearchRequestImpl();
+      request.setBase(new Dn(baseDn));
+      request.setScope(SearchScope.SUBTREE);
+      request.setFilter(filter);
+      request.addAttributes("*", "+");
+
+      List<Entry> results = new ArrayList<>();
+      try (SearchCursor cursor = conn.search(request)) {
+        while (cursor.next()) {
+          if (cursor.isEntry()) {
+            results.add(cursor.getEntry());
+          }
+        }
+      }
+      return results;
+    } catch (LdapException e) {
+      throw mapException(e);
+    } catch (Exception e) {
+      throw mapException(e);
+    } finally {
+      connectionManager.releaseConnection(conn);
+    }
+  }
+
+  private ResourceException mapException(Exception e) {
+    if (e instanceof ResourceException re) {
+      return re;
+    }
+    LOG.error("LDAP operation failed", e);
+    return new ResourceException(500, "LDAP operation failed");
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfig.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfig.java
new file mode 100644
index 0000000..1d21188
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfig.java
@@ -0,0 +1,228 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Central configuration for the SCIM-to-LDAP bridge, loaded from {@code scim-ldap.yml} on the
+ * classpath. Holds LDAP connection settings (host, port, bind DN, TLS) and SCIM-to-LDAP
+ * attribute mappings for users and groups.
+ *
+ * <p>LDAP connection settings can be overridden at runtime via system properties such as
+ * {@code ldap.host}, {@code ldap.port}, {@code ldap.bind.dn}, {@code ldap.bind.password},
+ * {@code ldap.base.dn.users}, {@code ldap.base.dn.groups}, and {@code ldap.use.tls}.
+ * System properties take precedence over YAML values, which take precedence over built-in defaults.
+ */
+@ApplicationScoped
+public class ScimLdapConfig {
+
+  private static final Logger LOG = LoggerFactory.getLogger(ScimLdapConfig.class);
+  private static final String CONFIG_FILE = "scim-ldap.yml";
+
+  private String host = "localhost";
+  private int port = 389;
+  private String bindDn = "cn=admin,dc=example,dc=com";
+  private String bindPassword = "secret";
+  private String userBaseDn = "ou=users,dc=example,dc=com";
+  private String groupBaseDn = "ou=groups,dc=example,dc=com";
+  private boolean useTls = false;
+  private boolean embedded = false;
+
+  private List<String> userObjectClasses;
+  private String userRdnAttribute;
+  private Map<String, String> userAttributes;
+
+  private List<String> groupObjectClasses;
+  private String groupRdnAttribute;
+  private Map<String, String> groupAttributes;
+
+  protected ScimLdapConfig() {}
+
+  /**
+   * Creates a configuration with explicit LDAP connection settings, primarily for
+   * programmatic or test use where loading from {@code scim-ldap.yml} is not desired.
+   *
+   * @param host         the LDAP server hostname
+   * @param port         the LDAP server port
+   * @param bindDn       the DN used to bind (authenticate) to the LDAP server
+   * @param bindPassword the password for the bind DN
+   * @param userBaseDn   the base DN under which user entries are stored
+   * @param groupBaseDn  the base DN under which group entries are stored
+   * @param useTls       {@code true} to enable TLS for LDAP connections
+   */
+  public ScimLdapConfig(String host, int port, String bindDn, String bindPassword,
+                         String userBaseDn, String groupBaseDn, boolean useTls) {
+    this.host = host;
+    this.port = port;
+    this.bindDn = bindDn;
+    this.bindPassword = bindPassword;
+    this.userBaseDn = userBaseDn;
+    this.groupBaseDn = groupBaseDn;
+    this.useTls = useTls;
+  }
+
+  @SuppressWarnings("unchecked")
+  @PostConstruct
+  protected void init() {
+    Map<String, Object> config = loadYaml();
+
+    // LDAP connection settings (YAML values, then system property overrides)
+    Map<String, Object> ldap = (Map<String, Object>) config.getOrDefault("ldap", Collections.emptyMap());
+    host = resolveString("ldap.host", ldap, "host", host);
+    bindDn = resolveString("ldap.bind.dn", ldap, "bindDn", bindDn);
+    bindPassword = resolveString("ldap.bind.password", ldap, "bindPassword", bindPassword);
+    userBaseDn = resolveString("ldap.base.dn.users", ldap, "userBaseDn", userBaseDn);
+    groupBaseDn = resolveString("ldap.base.dn.groups", ldap, "groupBaseDn", groupBaseDn);
+    useTls = Boolean.parseBoolean(resolveString("ldap.use.tls", ldap, "useTls", String.valueOf(useTls)));
+    embedded = Boolean.parseBoolean(resolveString("ldap.embedded", ldap, "embedded", String.valueOf(embedded)));
+
+    String portStr = resolveString("ldap.port", ldap, "port", String.valueOf(port));
+    try {
+      port = Integer.parseInt(portStr.trim());
+    } catch (NumberFormatException e) {
+      throw new IllegalArgumentException(
+        "Invalid ldap.port value: '" + portStr + "'. Must be a valid integer.", e);
+    }
+
+    // User mapping
+    Map<String, Object> user = (Map<String, Object>) config.getOrDefault("user", Collections.emptyMap());
+    userObjectClasses = (List<String>) user.getOrDefault("objectClasses",
+      List.of("inetOrgPerson", "organizationalPerson", "person", "top"));
+    userRdnAttribute = (String) user.getOrDefault("rdnAttribute", "uid");
+    userAttributes = new LinkedHashMap<>((Map<String, String>) user.getOrDefault("attributes", defaultUserAttributes()));
+
+    // Group mapping
+    Map<String, Object> group = (Map<String, Object>) config.getOrDefault("group", Collections.emptyMap());
+    groupObjectClasses = (List<String>) group.getOrDefault("objectClasses",
+      List.of("groupOfNames", "top"));
+    groupRdnAttribute = (String) group.getOrDefault("rdnAttribute", "cn");
+    groupAttributes = new LinkedHashMap<>((Map<String, String>) group.getOrDefault("attributes", defaultGroupAttributes()));
+
+    LOG.debug("LDAP configuration: host={}, port={}, bindDn={}, userBaseDn={}, groupBaseDn={}, useTls={}, embedded={}",
+      host, port, bindDn, userBaseDn, groupBaseDn, useTls, embedded);
+
+    if (!useTls && bindPassword != null && !bindPassword.isEmpty()) {
+      LOG.warn("TLS is disabled — bind credentials will be sent in plaintext. Set ldap.use-tls=true for production.");
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private Map<String, Object> loadYaml() {
+    try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(CONFIG_FILE)) {
+      if (is == null) {
+        LOG.warn("No {} found on classpath, using defaults", CONFIG_FILE);
+        return Collections.emptyMap();
+      }
+      ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
+      Map<String, Object> config = yaml.readValue(is, Map.class);
+      LOG.info("Loaded configuration from {}", CONFIG_FILE);
+      return config;
+    } catch (Exception e) {
+      throw new IllegalStateException("Failed to load " + CONFIG_FILE, e);
+    }
+  }
+
+  private static String resolveString(String sysPropKey, Map<String, Object> yamlSection,
+                                       String yamlKey, String defaultValue) {
+    String yamlValue = yamlSection.containsKey(yamlKey)
+      ? String.valueOf(yamlSection.get(yamlKey))
+      : defaultValue;
+    return System.getProperty(sysPropKey, yamlValue);
+  }
+
+  private static Map<String, String> defaultUserAttributes() {
+    Map<String, String> attrs = new LinkedHashMap<>();
+    attrs.put("userName", "uid");
+    attrs.put("name.givenName", "givenName");
+    attrs.put("name.familyName", "sn");
+    attrs.put("name.formatted", "cn");
+    attrs.put("displayName", "displayName");
+    attrs.put("emails.value", "mail");
+    attrs.put("phoneNumbers.value", "telephoneNumber");
+    attrs.put("addresses.streetAddress", "street");
+    attrs.put("addresses.locality", "l");
+    attrs.put("addresses.postalCode", "postalCode");
+    attrs.put("title", "title");
+    attrs.put("userType", "employeeType");
+    attrs.put("password", "userPassword");
+    return attrs;
+  }
+
+  private static Map<String, String> defaultGroupAttributes() {
+    Map<String, String> attrs = new LinkedHashMap<>();
+    attrs.put("displayName", "cn");
+    attrs.put("members.value", "member");
+    return attrs;
+  }
+
+  /** Returns the LDAP server hostname. */
+  public String getHost() { return host; }
+
+  /** Returns the LDAP server port. */
+  public int getPort() { return port; }
+
+  /** Returns the DN used to bind to the LDAP server. */
+  public String getBindDn() { return bindDn; }
+
+  /** Returns the password for the bind DN. */
+  public String getBindPassword() { return bindPassword; }
+
+  /** Returns the base DN under which user entries are stored. */
+  public String getUserBaseDn() { return userBaseDn; }
+
+  /** Returns the base DN under which group entries are stored. */
+  public String getGroupBaseDn() { return groupBaseDn; }
+
+  /** Returns {@code true} if TLS is enabled for LDAP connections. */
+  public boolean isUseTls() { return useTls; }
+
+  /** Returns {@code true} if an embedded ApacheDS server should be started. */
+  public boolean isEmbedded() { return embedded; }
+
+  /** Returns the LDAP object classes used when creating user entries. */
+  public List<String> getUserObjectClasses() { return userObjectClasses; }
+
+  /** Returns the RDN attribute name for user entries (e.g. {@code uid}). */
+  public String getUserRdnAttribute() { return userRdnAttribute; }
+
+  /** Returns the SCIM-to-LDAP attribute mapping for users, keyed by SCIM attribute path. */
+  public Map<String, String> getUserAttributes() { return userAttributes; }
+
+  /** Returns the LDAP object classes used when creating group entries. */
+  public List<String> getGroupObjectClasses() { return groupObjectClasses; }
+
+  /** Returns the RDN attribute name for group entries (e.g. {@code cn}). */
+  public String getGroupRdnAttribute() { return groupRdnAttribute; }
+
+  /** Returns the SCIM-to-LDAP attribute mapping for groups, keyed by SCIM attribute path. */
+  public Map<String, String> getGroupAttributes() { return groupAttributes; }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/AttributeMapper.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/AttributeMapper.java
new file mode 100644
index 0000000..706ba73
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/AttributeMapper.java
@@ -0,0 +1,573 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.mapping;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.apache.directory.api.ldap.model.entry.Attribute;
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Value;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapInvalidAttributeValueException;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.name.Rdn;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.spec.resources.Address;
+import org.apache.directory.scim.spec.resources.Email;
+import org.apache.directory.scim.spec.resources.GroupMembership;
+import org.apache.directory.scim.spec.resources.Name;
+import org.apache.directory.scim.spec.resources.PhoneNumber;
+import org.apache.directory.scim.spec.resources.ScimGroup;
+import org.apache.directory.scim.spec.resources.ScimUser;
+import org.apache.directory.scim.spec.schema.Meta;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoField;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Converts between SCIM resources ({@link ScimUser}, {@link ScimGroup}) and Apache Directory
+ * LDAP {@link Entry} objects using configurable attribute mappings from {@link ScimLdapConfig}.
+ *
+ * <p>This CDI bean reads its SCIM-to-LDAP attribute mapping at startup and provides
+ * bidirectional conversion methods as well as lookup helpers used by
+ * {@link FilterTranslator} to resolve SCIM attribute paths to their LDAP counterparts.
+ * DN construction follows RFC 4514 escaping via {@link Rdn}.</p>
+ */
+@ApplicationScoped
+public class AttributeMapper {
+
+  private static final Logger LOG = LoggerFactory.getLogger(AttributeMapper.class);
+
+  /**
+   * Formatter for LDAP GeneralizedTime (RFC 4517), e.g. {@code "20240115143000Z"} or
+   * {@code "20240115143000.12Z"}. {@code appendFraction} handles 1–3 fractional-second
+   * digits uniformly; a simple pattern string cannot do this correctly.
+   */
+  private static final java.time.format.DateTimeFormatter GENERALIZED_TIME =
+    new DateTimeFormatterBuilder()
+      .appendPattern("yyyyMMddHHmmss")
+      .optionalStart()
+      .appendFraction(ChronoField.NANO_OF_SECOND, 1, 3, true)
+      .optionalEnd()
+      .appendPattern("X")
+      .toFormatter();
+
+  @Inject
+  ScimLdapConfig properties;
+
+  private Map<String, String> scimToLdapUser;
+  private Map<String, String> scimToLdapGroup;
+
+  private List<String> userObjectClasses;
+  private String userRdnAttribute;
+  private List<String> groupObjectClasses;
+  private String groupRdnAttribute;
+
+  protected AttributeMapper() {}
+
+  @PostConstruct
+  void init() {
+    userObjectClasses = properties.getUserObjectClasses();
+    userRdnAttribute = properties.getUserRdnAttribute();
+    scimToLdapUser = new LinkedHashMap<>(properties.getUserAttributes());
+
+    groupObjectClasses = properties.getGroupObjectClasses();
+    groupRdnAttribute = properties.getGroupRdnAttribute();
+    scimToLdapGroup = new LinkedHashMap<>(properties.getGroupAttributes());
+
+    LOG.info("Attribute mapping initialized: user objectClasses={}, group objectClasses={}",
+      userObjectClasses, groupObjectClasses);
+  }
+
+  /**
+   * Maps an LDAP {@link Entry} to a {@link ScimUser}, populating standard SCIM core
+   * attributes (userName, name, displayName, emails, phoneNumbers, addresses, title,
+   * userType, and active) according to the configured attribute mapping.
+   *
+   * <p>The SCIM {@code id} is taken from the LDAP operational attribute {@code entryUUID}.</p>
+   *
+   * @param entry the LDAP entry to convert
+   * @return a populated {@link ScimUser}; fields for which no LDAP value exists are left unset
+   * @throws LdapInvalidAttributeValueException if an LDAP attribute value cannot be read
+   */
+  public ScimUser toScimUser(Entry entry) throws LdapInvalidAttributeValueException {
+    ScimUser user = new ScimUser();
+
+    // id from entryUUID (operational attribute)
+    String entryUuid = getStringAttribute(entry, "entryUUID");
+    if (entryUuid != null) {
+      user.setId(entryUuid);
+    }
+
+    // userName
+    String uid = getStringAttribute(entry, scimToLdapUser.get("userName"));
+    if (uid != null) {
+      user.setUserName(uid);
+    }
+
+    // name
+    Name name = new Name();
+    boolean hasName = false;
+    String givenName = getStringAttribute(entry, scimToLdapUser.get("name.givenName"));
+    if (givenName != null) {
+      name.setGivenName(givenName);
+      hasName = true;
+    }
+    String familyName = getStringAttribute(entry, scimToLdapUser.get("name.familyName"));
+    if (familyName != null) {
+      name.setFamilyName(familyName);
+      hasName = true;
+    }
+    String formatted = getStringAttribute(entry, scimToLdapUser.get("name.formatted"));
+    if (formatted != null) {
+      name.setFormatted(formatted);
+      hasName = true;
+    }
+    if (hasName) {
+      user.setName(name);
+    }
+
+    // displayName
+    String displayName = getStringAttribute(entry, scimToLdapUser.get("displayName"));
+    if (displayName != null) {
+      user.setDisplayName(displayName);
+    }
+
+    // emails (multi-valued)
+    List<Email> emails = getMultiValuedAsEmails(entry, scimToLdapUser.get("emails.value"));
+    if (!emails.isEmpty()) {
+      user.setEmails(emails);
+    }
+
+    // phoneNumbers (multi-valued)
+    List<PhoneNumber> phones = getMultiValuedAsPhoneNumbers(entry, scimToLdapUser.get("phoneNumbers.value"));
+    if (!phones.isEmpty()) {
+      user.setPhoneNumbers(phones);
+    }
+
+    // addresses
+    Address address = new Address();
+    boolean hasAddress = false;
+    String street = getStringAttribute(entry, scimToLdapUser.get("addresses.streetAddress"));
+    if (street != null) {
+      address.setStreetAddress(street);
+      hasAddress = true;
+    }
+    String locality = getStringAttribute(entry, scimToLdapUser.get("addresses.locality"));
+    if (locality != null) {
+      address.setLocality(locality);
+      hasAddress = true;
+    }
+    String postalCode = getStringAttribute(entry, scimToLdapUser.get("addresses.postalCode"));
+    if (postalCode != null) {
+      address.setPostalCode(postalCode);
+      hasAddress = true;
+    }
+    if (hasAddress) {
+      user.setAddresses(List.of(address));
+    }
+
+    // title
+    String title = getStringAttribute(entry, scimToLdapUser.get("title"));
+    if (title != null) {
+      user.setTitle(title);
+    }
+
+    // userType
+    String userType = getStringAttribute(entry, scimToLdapUser.get("userType"));
+    if (userType != null) {
+      user.setUserType(userType);
+    }
+
+    // active (stored as string "true"/"false" in custom attribute)
+    String activeStr = getStringAttribute(entry, "scimActive");
+    user.setActive(activeStr == null || Boolean.parseBoolean(activeStr));
+
+    // meta — version (ETag), created, lastModified from LDAP operational attributes
+    String modifyTs = getStringAttribute(entry, "modifyTimestamp");
+    String createTs = getStringAttribute(entry, "createTimestamp");
+    Meta meta = new Meta()
+      .setResourceType("User")
+      .setCreated(parseLdapTimestamp(createTs))
+      .setLastModified(parseLdapTimestamp(modifyTs));
+    // Fall back to createTimestamp when entry has never been modified
+    String etagSource = modifyTs != null ? modifyTs : createTs;
+    if (etagSource != null) {
+      meta.setVersion("W/\"" + etagSource + "\"");
+    }
+    user.setMeta(meta);
+
+    return user;
+  }
+
+  /**
+   * Converts a {@link ScimUser} into an LDAP {@link Entry} suitable for an add operation.
+   *
+   * <p>The entry's DN is constructed from the configured RDN attribute and the user's
+   * {@code userName}, placed under the given {@code baseDn}. Required
+   * {@code inetOrgPerson} attributes ({@code sn}, {@code cn}) are filled with sensible
+   * defaults when not explicitly mapped. Phone-number type metadata is stored in a
+   * custom {@code scimPhoneTypes} attribute for round-trip fidelity.</p>
+   *
+   * @param user   the SCIM user to convert
+   * @param baseDn the LDAP base DN under which the entry will be created
+   * @return a fully populated {@link Entry} ready to be added to the directory
+   * @throws LdapException              if the DN cannot be constructed or an attribute is invalid
+   * @throws IllegalArgumentException   if the user's {@code userName} is {@code null} or blank
+   */
+  public Entry toEntry(ScimUser user, String baseDn) throws LdapException {
+    String rdnValue = user.getUserName();
+    if (rdnValue == null || rdnValue.isBlank()) {
+      throw new IllegalArgumentException("userName is required to create an LDAP entry");
+    }
+
+    Dn dn = buildDn(userRdnAttribute, rdnValue, baseDn);
+    Entry entry = new DefaultEntry(dn);
+    entry.add("objectClass", userObjectClasses.toArray(new String[0]));
+
+    entry.add(scimToLdapUser.get("userName"), user.getUserName());
+
+    if (user.getName() != null) {
+      if (user.getName().getGivenName() != null) {
+        entry.add(scimToLdapUser.get("name.givenName"), user.getName().getGivenName());
+      }
+      if (user.getName().getFamilyName() != null) {
+        entry.add(scimToLdapUser.get("name.familyName"), user.getName().getFamilyName());
+      }
+      if (user.getName().getFormatted() != null) {
+        entry.add(scimToLdapUser.get("name.formatted"), user.getName().getFormatted());
+      }
+    }
+
+    // Ensure required inetOrgPerson attributes
+    if (!entry.containsAttribute("sn")) {
+      entry.add("sn", user.getUserName());
+    }
+    if (!entry.containsAttribute("cn")) {
+      String cn = user.getDisplayName() != null ? user.getDisplayName() : user.getUserName();
+      entry.add("cn", cn);
+    }
+
+    if (user.getDisplayName() != null) {
+      entry.add(scimToLdapUser.get("displayName"), user.getDisplayName());
+    }
+
+    if (user.getEmails() != null) {
+      for (Email email : user.getEmails()) {
+        if (email.getValue() != null) {
+          entry.add(scimToLdapUser.get("emails.value"), email.getValue());
+        }
+      }
+    }
+
+    if (user.getPhoneNumbers() != null) {
+      for (PhoneNumber phone : user.getPhoneNumbers()) {
+        if (phone.getValue() != null) {
+          entry.add(scimToLdapUser.get("phoneNumbers.value"), phone.getValue());
+        }
+      }
+      // Store phone type metadata in a custom attribute for round-trip fidelity
+      StringBuilder phoneTypes = new StringBuilder();
+      for (PhoneNumber phone : user.getPhoneNumbers()) {
+        if (phoneTypes.length() > 0) {
+          phoneTypes.append(",");
+        }
+        phoneTypes.append(phone.getType() != null ? phone.getType() : "");
+      }
+      entry.add("scimPhoneTypes", phoneTypes.toString());
+    }
+
+    if (user.getAddresses() != null && !user.getAddresses().isEmpty()) {
+      Address addr = user.getAddresses().get(0);
+      if (addr.getStreetAddress() != null) {
+        entry.add(scimToLdapUser.get("addresses.streetAddress"), addr.getStreetAddress());
+      }
+      if (addr.getLocality() != null) {
+        entry.add(scimToLdapUser.get("addresses.locality"), addr.getLocality());
+      }
+      if (addr.getPostalCode() != null) {
+        entry.add(scimToLdapUser.get("addresses.postalCode"), addr.getPostalCode());
+      }
+    }
+
+    if (user.getTitle() != null) {
+      entry.add(scimToLdapUser.get("title"), user.getTitle());
+    }
+    if (user.getUserType() != null) {
+      entry.add(scimToLdapUser.get("userType"), user.getUserType());
+    }
+    if (user.getPassword() != null) {
+      entry.add(scimToLdapUser.get("password"), user.getPassword());
+    }
+
+    // active flag (custom attribute)
+    if (user.getActive() != null) {
+      entry.add("scimActive", user.getActive().toString());
+    }
+
+    return entry;
+  }
+
+  /**
+   * Maps an LDAP {@link Entry} to a {@link ScimGroup}, populating the display name and
+   * group members according to the configured attribute mapping.
+   *
+   * <p>Member DNs are stored as {@link GroupMembership#setValue(String) membership values};
+   * the repository layer is responsible for resolving them to {@code entryUUID} identifiers
+   * if needed. Empty member placeholders (used by some LDAP servers for
+   * {@code groupOfNames} compliance) are skipped.</p>
+   *
+   * @param entry the LDAP entry to convert
+   * @return a populated {@link ScimGroup}
+   * @throws LdapInvalidAttributeValueException if an LDAP attribute value cannot be read
+   */
+  public ScimGroup toScimGroup(Entry entry) throws LdapInvalidAttributeValueException {
+    ScimGroup group = new ScimGroup();
+
+    String entryUuid = getStringAttribute(entry, "entryUUID");
+    if (entryUuid != null) {
+      group.setId(entryUuid);
+    }
+
+    String displayName = getStringAttribute(entry, scimToLdapGroup.get("displayName"));
+    if (displayName != null) {
+      group.setDisplayName(displayName);
+    }
+
+    // members (multi-valued DNs)
+    String memberAttr = scimToLdapGroup.get("members.value");
+    Attribute members = entry.get(memberAttr);
+    if (members != null) {
+      List<GroupMembership> memberList = new ArrayList<>();
+      for (Value val : members) {
+        String memberDn = val.getString();
+        // Skip empty member placeholder used by some LDAP servers
+        if (memberDn != null && !memberDn.isBlank()) {
+          GroupMembership membership = new GroupMembership();
+          // Store the DN as the value; the repository will resolve to entryUUID if needed
+          membership.setValue(memberDn);
+          memberList.add(membership);
+        }
+      }
+      group.setMembers(memberList);
+    }
+
+    // meta — version (ETag), created, lastModified from LDAP operational attributes
+    String modifyTs = getStringAttribute(entry, "modifyTimestamp");
+    String createTs = getStringAttribute(entry, "createTimestamp");
+    Meta meta = new Meta()
+      .setResourceType("Group")
+      .setCreated(parseLdapTimestamp(createTs))
+      .setLastModified(parseLdapTimestamp(modifyTs));
+    String etagSource = modifyTs != null ? modifyTs : createTs;
+    if (etagSource != null) {
+      meta.setVersion("W/\"" + etagSource + "\"");
+    }
+    group.setMeta(meta);
+
+    return group;
+  }
+
+  /**
+   * Converts a {@link ScimGroup} into an LDAP {@link Entry} suitable for an add operation.
+   *
+   * <p>The entry's DN is constructed from the configured group RDN attribute and the
+   * group's {@code displayName}, placed under the given {@code baseDn}. If the group
+   * has no members, an empty placeholder value is added to satisfy the
+   * {@code groupOfNames} schema requirement for at least one {@code member} attribute.</p>
+   *
+   * @param group  the SCIM group to convert
+   * @param baseDn the LDAP base DN under which the entry will be created
+   * @return a fully populated {@link Entry} ready to be added to the directory
+   * @throws LdapException            if the DN cannot be constructed or an attribute is invalid
+   * @throws IllegalArgumentException if the group's {@code displayName} is {@code null} or blank
+   */
+  public Entry toEntry(ScimGroup group, String baseDn) throws LdapException {
+    String rdnValue = group.getDisplayName();
+    if (rdnValue == null || rdnValue.isBlank()) {
+      throw new IllegalArgumentException("displayName is required to create an LDAP group entry");
+    }
+
+    Dn dn = buildDn(groupRdnAttribute, rdnValue, baseDn);
+    Entry entry = new DefaultEntry(dn);
+    entry.add("objectClass", groupObjectClasses.toArray(new String[0]));
+    entry.add(scimToLdapGroup.get("displayName"), group.getDisplayName());
+
+    if (group.getMembers() != null && !group.getMembers().isEmpty()) {
+      for (GroupMembership member : group.getMembers()) {
+        if (member.getValue() != null) {
+          entry.add(scimToLdapGroup.get("members.value"), member.getValue());
+        }
+      }
+    } else {
+      // groupOfNames requires at least one member; use a placeholder
+      entry.add(scimToLdapGroup.get("members.value"), "");
+    }
+
+    return entry;
+  }
+
+  /**
+   * Resolves a SCIM user attribute path (e.g. {@code "userName"} or {@code "name.givenName"})
+   * to the corresponding LDAP attribute name.
+   *
+   * @param scimAttribute the SCIM attribute path
+   * @return the mapped LDAP attribute name, or {@code null} if no mapping exists
+   */
+  public String getLdapUserAttribute(String scimAttribute) {
+    return scimToLdapUser.get(scimAttribute);
+  }
+
+  /**
+   * Resolves a SCIM group attribute path (e.g. {@code "displayName"} or {@code "members.value"})
+   * to the corresponding LDAP attribute name.
+   *
+   * @param scimAttribute the SCIM attribute path
+   * @return the mapped LDAP attribute name, or {@code null} if no mapping exists
+   */
+  public String getLdapGroupAttribute(String scimAttribute) {
+    return scimToLdapGroup.get(scimAttribute);
+  }
+
+  /** Returns the LDAP attribute used as the RDN for user entries (e.g. {@code "uid"}). */
+  public String getUserRdnAttribute() {
+    return userRdnAttribute;
+  }
+
+  /** Returns the LDAP attribute used as the RDN for group entries (e.g. {@code "cn"}). */
+  public String getGroupRdnAttribute() {
+    return groupRdnAttribute;
+  }
+
+  /** Returns the LDAP object classes assigned to user entries. */
+  public List<String> getUserObjectClasses() {
+    return userObjectClasses;
+  }
+
+  /** Returns the LDAP object classes assigned to group entries. */
+  public List<String> getGroupObjectClasses() {
+    return groupObjectClasses;
+  }
+
+  /**
+   * Parses an LDAP GeneralizedTime string (RFC 4517) to a UTC {@link LocalDateTime}.
+   *
+   * @param value the GeneralizedTime string, e.g. {@code "20240115143000Z"}
+   * @return the parsed UTC instant as a {@link LocalDateTime}, or {@code null} if the value
+   *         is blank or cannot be parsed
+   */
+  private static LocalDateTime parseLdapTimestamp(String value) {
+    if (value == null || value.isBlank()) {
+      return null;
+    }
+    try {
+      return ZonedDateTime.parse(value, GENERALIZED_TIME)
+        .withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
+    } catch (DateTimeParseException e) {
+      LOG.warn("Failed to parse LDAP GeneralizedTime '{}': {}", value, e.getMessage());
+      return null;
+    }
+  }
+
+  private static Dn buildDn(String rdnAttribute, String rdnValue, String baseDn) throws LdapException {
+    return new Dn(new Rdn(rdnAttribute, rdnValue), new Dn(baseDn));
+  }
+
+  private String getStringAttribute(Entry entry, String ldapAttrName) throws LdapInvalidAttributeValueException {
+    if (ldapAttrName == null) {
+      return null;
+    }
+    Attribute attr = entry.get(ldapAttrName);
+    if (attr != null) {
+      return attr.getString();
+    }
+    return null;
+  }
+
+  private List<Email> getMultiValuedAsEmails(Entry entry, String ldapAttrName) throws LdapInvalidAttributeValueException {
+    List<Email> result = new ArrayList<>();
+    if (ldapAttrName == null) {
+      return result;
+    }
+    Attribute attr = entry.get(ldapAttrName);
+    if (attr != null) {
+      boolean first = true;
+      for (Value val : attr) {
+        Email email = new Email();
+        email.setValue(val.getString());
+        if (first) {
+          email.setPrimary(true);
+          first = false;
+        }
+        result.add(email);
+      }
+    }
+    return result;
+  }
+
+  private List<PhoneNumber> getMultiValuedAsPhoneNumbers(Entry entry, String ldapAttrName) throws LdapInvalidAttributeValueException {
+    List<PhoneNumber> result = new ArrayList<>();
+    if (ldapAttrName == null) {
+      return result;
+    }
+    Attribute attr = entry.get(ldapAttrName);
+    if (attr != null) {
+      // Read stored type metadata if available
+      String[] types = null;
+      String typeStr = getStringAttribute(entry, "scimPhoneTypes");
+      if (typeStr != null) {
+        types = typeStr.split(",", -1);
+      }
+
+      boolean first = true;
+      int idx = 0;
+      for (Value val : attr) {
+        try {
+          PhoneNumber phone = new PhoneNumber();
+          phone.setValue(val.getString());
+          if (types != null && idx < types.length && !types[idx].isEmpty()) {
+            phone.setType(types[idx]);
+          }
+          if (first) {
+            phone.setPrimary(true);
+            first = false;
+          }
+          result.add(phone);
+        } catch (Exception e) {
+          LOG.warn("Skipping unparseable phone number: {}", val.getString(), e);
+        }
+        idx++;
+      }
+    }
+    return result;
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/FilterTranslator.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/FilterTranslator.java
new file mode 100644
index 0000000..05c453f
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/FilterTranslator.java
@@ -0,0 +1,191 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.mapping;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.apache.directory.api.ldap.model.filter.AndNode;
+import org.apache.directory.api.ldap.model.filter.EqualityNode;
+import org.apache.directory.api.ldap.model.filter.ExprNode;
+import org.apache.directory.api.ldap.model.filter.GreaterEqNode;
+import org.apache.directory.api.ldap.model.filter.LessEqNode;
+import org.apache.directory.api.ldap.model.filter.NotNode;
+import org.apache.directory.api.ldap.model.filter.ObjectClassNode;
+import org.apache.directory.api.ldap.model.filter.OrNode;
+import org.apache.directory.api.ldap.model.filter.PresenceNode;
+import org.apache.directory.api.ldap.model.filter.SubstringNode;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.spec.filter.AttributeComparisonExpression;
+import org.apache.directory.scim.spec.filter.AttributePresentExpression;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterExpression;
+import org.apache.directory.scim.spec.filter.FilterExpressionVisitor;
+import org.apache.directory.scim.spec.filter.GroupExpression;
+import org.apache.directory.scim.spec.filter.LogicalExpression;
+import org.apache.directory.scim.spec.filter.LogicalOperator;
+import org.apache.directory.scim.spec.filter.ValuePathExpression;
+
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Translates SCIM {@link Filter} expressions into Apache Directory LDAP API
+ * {@link ExprNode} filter trees using the {@link FilterExpressionVisitor} pattern.
+ *
+ * <p>All SCIM comparison operators ({@code eq}, {@code ne}, {@code co}, {@code sw},
+ * {@code ew}, {@code gt}, {@code ge}, {@code lt}, {@code le}, {@code pr}) and logical
+ * operators ({@code and}, {@code or}, {@code not}) are supported. SCIM attribute names
+ * are resolved to their LDAP counterparts through the configurable mapping held by
+ * {@link AttributeMapper}. When no mapping is found the SCIM attribute name is used
+ * as-is, providing a best-effort fallback.</p>
+ *
+ * <p>Translation is performed by a private {@link FilterExpressionVisitor} implementation
+ * ({@code LdapFilterVisitor}) that dispatches each SCIM expression node to the appropriate
+ * LDAP filter construction method via {@link FilterExpression#accept(FilterExpressionVisitor)}.</p>
+ *
+ * <p>Filter values are escaped automatically by the {@link ExprNode} implementations,
+ * eliminating the risk of LDAP filter injection.</p>
+ */
+@ApplicationScoped
+public class FilterTranslator {
+
+  @Inject
+  AttributeMapper attributeMapper;
+
+  @Inject
+  ScimLdapConfig config;
+
+  protected FilterTranslator() {}
+
+  /**
+   * Builds a complete LDAP search filter for user queries by combining the configured
+   * user objectClass with the translated SCIM filter.
+   *
+   * @param filter the SCIM filter to translate, may be {@code null}
+   * @return an {@link ExprNode} combining the objectClass constraint with the SCIM filter
+   */
+  public ExprNode buildUserSearchFilter(Filter filter) {
+    ExprNode scimFilter = translateFilter(filter, attributeMapper::getLdapUserAttribute);
+    ExprNode objectClass = new EqualityNode<>("objectClass", config.getUserObjectClasses().get(0));
+    return new AndNode(objectClass, scimFilter);
+  }
+
+  /**
+   * Builds a complete LDAP search filter for group queries by combining the configured
+   * group objectClass with the translated SCIM filter.
+   *
+   * @param filter the SCIM filter to translate, may be {@code null}
+   * @return an {@link ExprNode} combining the objectClass constraint with the SCIM filter
+   */
+  public ExprNode buildGroupSearchFilter(Filter filter) {
+    ExprNode scimFilter = translateFilter(filter, attributeMapper::getLdapGroupAttribute);
+    ExprNode objectClass = new EqualityNode<>("objectClass", config.getGroupObjectClasses().get(0));
+    return new AndNode(objectClass, scimFilter);
+  }
+
+  private ExprNode translateFilter(Filter filter, Function<String, String> attrResolver) {
+    if (filter == null || filter.getExpression() == null) {
+      return ObjectClassNode.OBJECT_CLASS_NODE;
+    }
+    return filter.getExpression().accept(new LdapFilterVisitor(attrResolver));
+  }
+
+  /**
+   * A {@link FilterExpressionVisitor} that translates each SCIM filter expression node
+   * into the corresponding Apache Directory LDAP API {@link ExprNode}.
+   */
+  private static class LdapFilterVisitor implements FilterExpressionVisitor<ExprNode> {
+
+    private final Function<String, String> attrResolver;
+
+    LdapFilterVisitor(Function<String, String> attrResolver) {
+      this.attrResolver = attrResolver;
+    }
+
+    @Override
+    public ExprNode visit(AttributeComparisonExpression expr) {
+      String ldapAttr = resolveAttribute(expr);
+      String value = expr.getCompareValue() != null ? expr.getCompareValue().toString() : "";
+
+      try {
+        return switch (expr.getOperation()) {
+          case EQ -> new EqualityNode<>(ldapAttr, value);
+          case NE -> new NotNode(new EqualityNode<>(ldapAttr, value));
+          case CO -> new SubstringNode(List.of(value), ldapAttr, null, null);
+          case SW -> new SubstringNode(ldapAttr, value, null);
+          case EW -> new SubstringNode(ldapAttr, null, value);
+          case GT -> new AndNode(new GreaterEqNode<>(ldapAttr, value), new NotNode(new EqualityNode<>(ldapAttr, value)));
+          case GE -> new GreaterEqNode<>(ldapAttr, value);
+          case LT -> new AndNode(new LessEqNode<>(ldapAttr, value), new NotNode(new EqualityNode<>(ldapAttr, value)));
+          case LE -> new LessEqNode<>(ldapAttr, value);
+          case PR -> new PresenceNode(ldapAttr);
+        };
+      } catch (Exception e) {
+        throw new IllegalArgumentException("Failed to build LDAP filter for " + ldapAttr + " " + expr.getOperation(), e);
+      }
+    }
+
+    @Override
+    public ExprNode visit(AttributePresentExpression expr) {
+      String scimAttr = expr.getAttributePath().getAttributeName();
+      String ldapAttr = attrResolver.apply(scimAttr);
+      if (ldapAttr == null) {
+        ldapAttr = scimAttr;
+      }
+      return new PresenceNode(ldapAttr);
+    }
+
+    @Override
+    public ExprNode visit(LogicalExpression expr) {
+      ExprNode left = expr.getLeft().accept(this);
+      ExprNode right = expr.getRight().accept(this);
+      if (expr.getOperator() == LogicalOperator.AND) {
+        return new AndNode(left, right);
+      }
+      return new OrNode(left, right);
+    }
+
+    @Override
+    public ExprNode visit(GroupExpression expr) {
+      ExprNode inner = expr.getFilterExpression().accept(this);
+      if (expr.isNot()) {
+        return new NotNode(inner);
+      }
+      return inner;
+    }
+
+    @Override
+    public ExprNode visit(ValuePathExpression expr) {
+      if (expr.getAttributeExpression() != null) {
+        return expr.getAttributeExpression().accept(this);
+      }
+      throw new UnsupportedOperationException(
+        "ValuePathExpression without an attribute expression cannot be translated to LDAP");
+    }
+
+    private String resolveAttribute(AttributeComparisonExpression expr) {
+      String scimAttr = expr.getAttributePath().getAttributeName();
+      String subAttr = expr.getAttributePath().getSubAttributeName();
+      String scimPath = subAttr != null ? scimAttr + "." + subAttr : scimAttr;
+      String ldapAttr = attrResolver.apply(scimPath);
+      return ldapAttr != null ? ldapAttr : scimAttr;
+    }
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapGroupRepository.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapGroupRepository.java
new file mode 100644
index 0000000..412ccb1
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapGroupRepository.java
@@ -0,0 +1,394 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.service;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import org.apache.directory.api.ldap.model.entry.Attribute;
+import org.apache.directory.api.ldap.model.entry.DefaultModification;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.api.ldap.model.entry.ModificationOperation;
+import org.apache.directory.api.ldap.model.exception.LdapInvalidDnException;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.name.Rdn;
+import org.apache.directory.scim.core.repository.BaseRepository;
+import org.apache.directory.scim.core.repository.PatchHandler;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.apache.directory.scim.spec.resources.GroupMembership;
+import org.apache.directory.scim.spec.resources.ScimGroup;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * SCIMple {@link BaseRepository} implementation for SCIM {@link ScimGroup} resources backed by LDAP.
+ *
+ * <p>This repository stores and retrieves group data from an LDAP directory. It uses the
+ * LDAP {@code entryUUID} operational attribute as the SCIM resource identifier. In addition
+ * to standard CRUD operations, this class handles bidirectional resolution between SCIM
+ * member identifiers ({@code entryUUID} values) and LDAP member distinguished names.</p>
+ *
+ * <p>Attribute mapping between SCIM and LDAP is delegated to {@link AttributeMapper}.</p>
+ *
+ *
+ * @see BaseRepository
+ * @see AttributeMapper
+ */
+@Named
+@ApplicationScoped
+public class LdapGroupRepository extends BaseRepository<ScimGroup> {
+
+  private static final Logger LOG = LoggerFactory.getLogger(LdapGroupRepository.class);
+
+  private final LdapDao ldapDao;
+  private final AttributeMapper attributeMapper;
+  private final ScimLdapConfig properties;
+
+  /**
+   * Constructs a new {@code LdapGroupRepository} with the required CDI-managed dependencies.
+   *
+   * @param ldapDao          the data access object for LDAP operations (search, create, modify, delete)
+   * @param attributeMapper  maps between SCIM {@link ScimGroup} attributes and LDAP entry attributes
+   * @param properties       LDAP configuration properties including base DNs
+   * @param patchHandler     applies SCIM PATCH operations to produce an updated resource
+   */
+  @Inject
+  public LdapGroupRepository(LdapDao ldapDao, AttributeMapper attributeMapper,
+                              ScimLdapConfig properties, PatchHandler patchHandler) {
+    super(ScimGroup.class, patchHandler);
+    this.ldapDao = ldapDao;
+    this.attributeMapper = attributeMapper;
+    this.properties = properties;
+  }
+
+  /**
+   * Constructs a new {@code LdapGroupRepository} without a {@link PatchHandler}.
+   * Useful for testing when patch behavior is not under test.
+   */
+  LdapGroupRepository(LdapDao ldapDao, AttributeMapper attributeMapper, ScimLdapConfig properties) {
+    super(ScimGroup.class, null);
+    this.ldapDao = ldapDao;
+    this.attributeMapper = attributeMapper;
+    this.properties = properties;
+  }
+
+  protected LdapGroupRepository() {
+    super();
+    this.ldapDao = null;
+    this.attributeMapper = null;
+    this.properties = null;
+  }
+
+  /**
+   * Creates a new group in LDAP and returns the resulting SCIM representation.
+   *
+   * <p>Before creating the LDAP entry, member SCIM identifiers ({@code entryUUID} values)
+   * are resolved to LDAP distinguished names. After creation, the entry is read back to
+   * obtain the server-assigned {@code entryUUID}, and member DNs are resolved back to
+   * SCIM identifiers in the returned resource.</p>
+   *
+   * @param resource       the SCIM group to create
+   * @param requestContext the current SCIM request context
+   * @return the created {@link ScimGroup} with the server-assigned {@code id} and resolved members
+   * @throws ResourceException if member resolution or the LDAP operation fails
+   */
+  @Override
+  public ScimGroup create(ScimGroup resource, ScimRequestContext requestContext) throws ResourceException {
+    try {
+      // Resolve member SCIM ids to DNs
+      resolveMemberIds(resource);
+
+      Entry entry = attributeMapper.toEntry(resource, properties.getGroupBaseDn());
+      ldapDao.create(entry);
+
+      Entry created = ldapDao.lookup(entry.getDn().toString());
+      return toScimGroupWithResolvedMembers(created);
+    } catch (ResourceException e) {
+      throw e;
+    } catch (Exception e) {
+      LOG.error("Failed to create group", e);
+      throw new ResourceException(500, "Failed to create group");
+    }
+  }
+
+  /**
+   * Replaces an existing LDAP group with the given SCIM resource.
+   *
+   * <p>Locates the existing entry by searching for the {@code entryUUID} matching the given
+   * {@code id}. Member SCIM identifiers are resolved to LDAP DNs before building the
+   * LDAP modifications. The updated attributes are applied via {@code REPLACE_ATTRIBUTE}
+   * operations, and the modified entry is read back with member DNs resolved to SCIM
+   * identifiers.</p>
+   *
+   * @param id             the SCIM resource id (LDAP {@code entryUUID})
+   * @param resource       the replacement SCIM group data
+   * @param requestContext the current SCIM request context
+   * @return the updated {@link ScimGroup} as read back from LDAP with resolved members
+   * @throws ResourceNotFoundException if no entry with the given {@code entryUUID} exists
+   * @throws ResourceException         if member resolution or the LDAP operation fails
+   */
+  @Override
+  public ScimGroup update(String id, ScimGroup resource, ScimRequestContext requestContext) throws ResourceException {
+    try {
+      Entry existing = findByEntryUuid(id);
+      if (existing == null) {
+        throw new ResourceNotFoundException(id);
+      }
+      String dn = existing.getDn().toString();
+
+      resolveMemberIds(resource);
+
+      // Detect displayName change → LDAP modifyDn before attribute modifications.
+      // LDAP does not support multi-operation transactions (RFC 4511). If the
+      // rename succeeds but the subsequent modify fails, the entry will remain at
+      // the new DN with attributes only partially updated. Re-sending the request
+      // to the new DN will recover the entry to a consistent state.
+      String rdnAttr = attributeMapper.getGroupRdnAttribute();
+      Attribute currentRdnAttr = existing.get(rdnAttr);
+      String existingDisplayName = currentRdnAttr != null ? currentRdnAttr.getString() : null;
+      String newDisplayName = resource.getDisplayName();
+      if (newDisplayName != null && !newDisplayName.equals(existingDisplayName)) {
+        String newRdn = new Rdn(rdnAttr, newDisplayName).toString();
+        ldapDao.rename(dn, newRdn);
+        Dn newDn = new Dn(new Rdn(rdnAttr, newDisplayName), existing.getDn().getParent());
+        dn = newDn.toString();
+      }
+
+      Entry updated = attributeMapper.toEntry(resource, properties.getGroupBaseDn());
+
+      List<Modification> modifications = buildReplaceModifications(updated);
+      if (!modifications.isEmpty()) {
+        ldapDao.modify(dn, modifications.toArray(new Modification[0]));
+      }
+
+      Entry result = ldapDao.lookup(dn);
+      return toScimGroupWithResolvedMembers(result);
+    } catch (ResourceException e) {
+      throw e;
+    } catch (Exception e) {
+      LOG.error("Failed to update group", e);
+      throw new ResourceException(500, "Failed to update group");
+    }
+  }
+
+  /**
+   * Retrieves a single SCIM group by its id.
+   *
+   * <p>Performs an LDAP search under the group base DN for an entry whose {@code entryUUID}
+   * matches the given {@code id}. Member DNs in the resulting entry are resolved to SCIM
+   * identifiers. Returns {@code null} if no matching entry is found.</p>
+   *
+   * @param id             the SCIM resource id (LDAP {@code entryUUID})
+   * @param requestContext the current SCIM request context
+   * @return the matching {@link ScimGroup} with resolved members, or {@code null} if not found
+   * @throws ResourceException if the LDAP operation fails
+   */
+  @Override
+  public ScimGroup get(String id, ScimRequestContext requestContext) throws ResourceException {
+    try {
+      Entry entry = findByEntryUuid(id);
+      if (entry == null) {
+        return null;
+      }
+      return toScimGroupWithResolvedMembers(entry);
+    } catch (ResourceException e) {
+      throw e;
+    } catch (Exception e) {
+      LOG.error("Failed to get group", e);
+      throw new ResourceException(500, "Failed to get group");
+    }
+  }
+
+  /**
+   * Deletes a SCIM group from LDAP.
+   *
+   * <p>Resolves the SCIM {@code id} to an LDAP entry via an {@code entryUUID} search,
+   * then deletes the entry by its distinguished name.</p>
+   *
+   * @param id the SCIM resource id (LDAP {@code entryUUID})
+   * @throws ResourceNotFoundException if no entry with the given {@code entryUUID} exists
+   * @throws ResourceException         if the LDAP operation fails
+   */
+  @Override
+  public void delete(String id) throws ResourceException {
+    Entry entry = findByEntryUuid(id);
+    if (entry == null) {
+      throw new ResourceNotFoundException(id);
+    }
+    ldapDao.delete(entry.getDn().toString());
+  }
+
+  /**
+   * Searches for SCIM groups matching the given filter.
+   *
+   * <p>Delegates to {@link LdapDao#findGroups(Filter, PageRequest)} which translates the SCIM
+   * filter, performs a paged LDAP search under the group base DN, and applies SCIM pagination.
+   * The returned entries are mapped to {@link ScimGroup} instances with member DNs resolved
+   * to SCIM identifiers.</p>
+   *
+   * @param filter         the SCIM filter to apply, or {@code null} for all groups
+   * @param requestContext the current SCIM request context (includes pagination parameters)
+   * @return a {@link FilterResponse} containing the matching groups and total count
+   * @throws ResourceException if the LDAP search or mapping fails
+   */
+  @Override
+  public FilterResponse<ScimGroup> find(Filter filter, ScimRequestContext requestContext) throws ResourceException {
+    try {
+      // LdapDao handles LDAP paged results control and SCIM pagination
+      PageRequest pageRequest = requestContext.getPageRequest().orElse(null);
+      FilterResponse<Entry> ldapResults = ldapDao.findGroups(filter, pageRequest);
+
+      // Map LDAP entries to SCIM groups with resolved members
+      List<ScimGroup> groups = new ArrayList<>();
+      for (Entry entry : ldapResults.getResources()) {
+        try {
+          groups.add(toScimGroupWithResolvedMembers(entry));
+        } catch (Exception e) {
+          LOG.error("Failed to map LDAP entry to ScimGroup: {}", entry.getDn(), e);
+          throw new ResourceException(500, "Failed to map LDAP entry: " + entry.getDn());
+        }
+      }
+
+      return new FilterResponse<>(groups, ldapResults.getTotalResults());
+    } catch (ResourceException e) {
+      throw e;
+    } catch (Exception e) {
+      LOG.error("Failed to search groups", e);
+      throw new ResourceException(500, "Failed to search groups");
+    }
+  }
+
+  private Entry findByEntryUuid(String entryUuid) throws ResourceException {
+    return ldapDao.searchByAttribute(properties.getGroupBaseDn(), "entryUUID", entryUuid);
+  }
+
+  private ScimGroup toScimGroupWithResolvedMembers(Entry entry) throws Exception {
+    ScimGroup group = attributeMapper.toScimGroup(entry);
+
+    // Resolve member DNs to SCIM ids (entryUUIDs).
+    // NOTE: N+1 query pattern — each member DN triggers an individual LDAP lookup.
+    // Production deployments with large groups should consider batch resolution
+    // (e.g., a single search with an OR filter on all member DNs) to reduce round-trips.
+    if (group.getMembers() != null) {
+      List<GroupMembership> resolved = new ArrayList<>();
+      for (GroupMembership member : group.getMembers()) {
+        try {
+          // member.getValue() currently holds the DN
+          Entry memberEntry = ldapDao.lookup(member.getValue());
+          if (memberEntry != null) {
+            String memberUuid = memberEntry.get("entryUUID") != null
+              ? memberEntry.get("entryUUID").getString() : member.getValue();
+            // Prefer mail for display (matches SCIM convention), fall back to cn
+            String display = memberEntry.get("mail") != null
+              ? memberEntry.get("mail").getString()
+              : (memberEntry.get("cn") != null ? memberEntry.get("cn").getString() : null);
+
+            GroupMembership resolvedMember = new GroupMembership();
+            resolvedMember.setValue(memberUuid);
+            if (display != null) {
+              resolvedMember.setDisplay(display);
+            }
+            resolved.add(resolvedMember);
+          }
+        } catch (ResourceNotFoundException e) {
+          LOG.warn("Member DN not found (stale reference), skipping: {}", member.getValue());
+        }
+      }
+      group.setMembers(resolved);
+    }
+
+    return group;
+  }
+
+  private void resolveMemberIds(ScimGroup group) throws ResourceException {
+    if (group.getMembers() == null) {
+      return;
+    }
+    List<GroupMembership> resolved = new ArrayList<>();
+    for (GroupMembership member : group.getMembers()) {
+      String memberId = member.getValue();
+      if (memberId == null) {
+        continue;
+      }
+      // If it looks like a DN, validate it parses as one and is under a known base DN
+      if (memberId.contains("=")) {
+        validateMemberDn(memberId);
+        resolved.add(member);
+        continue;
+      }
+      // Otherwise treat it as an entryUUID and resolve to DN
+      Entry memberEntry = ldapDao.searchByAttribute(properties.getUserBaseDn(), "entryUUID", memberId);
+      if (memberEntry == null) {
+        // Also search in groups
+        memberEntry = ldapDao.searchByAttribute(properties.getGroupBaseDn(), "entryUUID", memberId);
+      }
+      if (memberEntry == null) {
+        throw new ResourceException(400, "Member not found: " + memberId);
+      }
+      GroupMembership resolvedMember = new GroupMembership();
+      resolvedMember.setValue(memberEntry.getDn().toString());
+      if (member.getDisplay() != null) {
+        resolvedMember.setDisplay(member.getDisplay());
+      }
+      resolved.add(resolvedMember);
+    }
+    group.setMembers(resolved);
+  }
+
+  private void validateMemberDn(String memberDn) throws ResourceException {
+    try {
+      Dn parsed = new Dn(memberDn);
+      Dn userDn = new Dn(properties.getUserBaseDn());
+      Dn groupDn = new Dn(properties.getGroupBaseDn());
+      if (!parsed.isDescendantOf(userDn) && !parsed.isDescendantOf(groupDn)) {
+        throw new ResourceException(400,
+          "Member DN is not under a recognized base DN: " + memberDn);
+      }
+    } catch (LdapInvalidDnException e) {
+      throw new ResourceException(400, "Invalid member DN: " + memberDn);
+    }
+  }
+
+  private List<Modification> buildReplaceModifications(Entry updated) {
+    List<Modification> modifications = new ArrayList<>();
+
+    for (Attribute attr : updated) {
+      String attrId = attr.getId();
+      if ("objectClass".equalsIgnoreCase(attrId) || attributeMapper.getGroupRdnAttribute().equalsIgnoreCase(attrId)) {
+        continue;
+      }
+      modifications.add(new DefaultModification(ModificationOperation.REPLACE_ATTRIBUTE, attr));
+    }
+
+    return modifications;
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapUserRepository.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapUserRepository.java
new file mode 100644
index 0000000..efe6011
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapUserRepository.java
@@ -0,0 +1,297 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.service;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import org.apache.directory.api.ldap.model.entry.Attribute;
+import org.apache.directory.api.ldap.model.entry.DefaultModification;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.api.ldap.model.entry.ModificationOperation;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.name.Rdn;
+import org.apache.directory.scim.core.repository.BaseRepository;
+import org.apache.directory.scim.core.repository.PatchHandler;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.apache.directory.scim.spec.resources.ScimUser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * SCIMple {@link BaseRepository} implementation for SCIM {@link ScimUser} resources backed by LDAP.
+ *
+ * <p>This repository stores and retrieves user data from an LDAP directory. It uses the
+ * LDAP {@code entryUUID} operational attribute as the SCIM resource identifier, ensuring
+ * a stable identity that is independent of the entry's distinguished name.</p>
+ *
+ * <p>Attribute mapping between SCIM and LDAP is delegated to {@link AttributeMapper}.</p>
+ *
+ * @see BaseRepository
+ * @see AttributeMapper
+ */
+@Named
+@ApplicationScoped
+public class LdapUserRepository extends BaseRepository<ScimUser> {
+
+  private static final Logger LOG = LoggerFactory.getLogger(LdapUserRepository.class);
+
+  private final LdapDao ldapDao;
+  private final AttributeMapper attributeMapper;
+  private final ScimLdapConfig properties;
+
+  /**
+   * Constructs a new {@code LdapUserRepository} with the required CDI-managed dependencies.
+   *
+   * @param ldapDao          the data access object for LDAP operations (search, create, modify, delete)
+   * @param attributeMapper  maps between SCIM {@link ScimUser} attributes and LDAP entry attributes
+   * @param properties       LDAP configuration properties including base DNs
+   * @param patchHandler     applies SCIM PATCH operations to produce an updated resource
+   */
+  @Inject
+  public LdapUserRepository(LdapDao ldapDao, AttributeMapper attributeMapper,
+                             ScimLdapConfig properties, PatchHandler patchHandler) {
+    super(ScimUser.class, patchHandler);
+    this.ldapDao = ldapDao;
+    this.attributeMapper = attributeMapper;
+    this.properties = properties;
+  }
+
+  /**
+   * Constructs a new {@code LdapUserRepository} without a {@link PatchHandler}.
+   * Useful for testing when patch behavior is not under test.
+   */
+  LdapUserRepository(LdapDao ldapDao, AttributeMapper attributeMapper, ScimLdapConfig properties) {
+    super(ScimUser.class, null);
+    this.ldapDao = ldapDao;
+    this.attributeMapper = attributeMapper;
+    this.properties = properties;
+  }
+
+  protected LdapUserRepository() {
+    super();
+    this.ldapDao = null;
+    this.attributeMapper = null;
+    this.properties = null;
+  }
+
+  /**
+   * Creates a new user in LDAP and returns the resulting SCIM representation.
+   *
+   * <p>The SCIM resource is mapped to an LDAP entry and added under the configured user base DN.
+   * After creation, the entry is read back from the directory to obtain the server-assigned
+   * {@code entryUUID}, which becomes the SCIM resource {@code id}.</p>
+   *
+   * @param resource       the SCIM user to create
+   * @param requestContext the current SCIM request context
+   * @return the created {@link ScimUser} with the server-assigned {@code id}
+   * @throws ResourceException if the LDAP operation fails
+   */
+  @Override
+  public ScimUser create(ScimUser resource, ScimRequestContext requestContext) throws ResourceException {
+    try {
+      Entry entry = attributeMapper.toEntry(resource, properties.getUserBaseDn());
+      ldapDao.create(entry);
+
+      // Read back to get the entryUUID assigned by the LDAP server
+      Entry created = ldapDao.lookup(entry.getDn().toString());
+      return attributeMapper.toScimUser(created);
+    } catch (ResourceException e) {
+      throw e;
+    } catch (Exception e) {
+      LOG.error("Failed to create user", e);
+      throw new ResourceException(500, "Failed to create user");
+    }
+  }
+
+  /**
+   * Replaces an existing LDAP user with the given SCIM resource.
+   *
+   * <p>Locates the existing entry by searching for the {@code entryUUID} matching the given
+   * {@code id}. The updated SCIM attributes are mapped to LDAP modifications (using
+   * {@code REPLACE_ATTRIBUTE} operations) and applied to the entry. The modified entry is
+   * then read back and returned as a SCIM user.</p>
+   *
+   * @param id             the SCIM resource id (LDAP {@code entryUUID})
+   * @param resource       the replacement SCIM user data
+   * @param requestContext the current SCIM request context
+   * @return the updated {@link ScimUser} as read back from LDAP
+   * @throws ResourceNotFoundException if no entry with the given {@code entryUUID} exists
+   * @throws ResourceException         if the LDAP operation fails
+   */
+  @Override
+  public ScimUser update(String id, ScimUser resource, ScimRequestContext requestContext) throws ResourceException {
+    try {
+      Entry existing = findByEntryUuid(id);
+      if (existing == null) {
+        throw new ResourceNotFoundException(id);
+      }
+      String dn = existing.getDn().toString();
+
+      // Detect userName change → LDAP modifyDn before attribute modifications.
+      // LDAP does not support multi-operation transactions (RFC 4511). If the
+      // rename succeeds but the subsequent modify fails, the entry will remain at
+      // the new DN with attributes only partially updated. Re-sending the request
+      // to the new DN will recover the entry to a consistent state.
+      String rdnAttr = attributeMapper.getUserRdnAttribute();
+      Attribute currentRdnAttr = existing.get(rdnAttr);
+      String existingUserName = currentRdnAttr != null ? currentRdnAttr.getString() : null;
+      String newUserName = resource.getUserName();
+      if (newUserName != null && !newUserName.equals(existingUserName)) {
+        String newRdn = new Rdn(rdnAttr, newUserName).toString();
+        ldapDao.rename(dn, newRdn);
+        // Parent DN is unchanged; only the RDN changes
+        Dn newDn = new Dn(new Rdn(rdnAttr, newUserName), existing.getDn().getParent());
+        dn = newDn.toString();
+      }
+
+      Entry updated = attributeMapper.toEntry(resource, properties.getUserBaseDn());
+
+      List<Modification> modifications = buildReplaceModifications(updated);
+      if (!modifications.isEmpty()) {
+        ldapDao.modify(dn, modifications.toArray(new Modification[0]));
+      }
+
+      Entry result = ldapDao.lookup(dn);
+      return attributeMapper.toScimUser(result);
+    } catch (ResourceException e) {
+      throw e;
+    } catch (Exception e) {
+      LOG.error("Failed to update user", e);
+      throw new ResourceException(500, "Failed to update user");
+    }
+  }
+
+  /**
+   * Retrieves a single SCIM user by its id.
+   *
+   * <p>Performs an LDAP search under the user base DN for an entry whose {@code entryUUID}
+   * matches the given {@code id}. Returns {@code null} if no matching entry is found.</p>
+   *
+   * @param id             the SCIM resource id (LDAP {@code entryUUID})
+   * @param requestContext the current SCIM request context
+   * @return the matching {@link ScimUser}, or {@code null} if not found
+   * @throws ResourceException if the LDAP operation fails
+   */
+  @Override
+  public ScimUser get(String id, ScimRequestContext requestContext) throws ResourceException {
+    try {
+      Entry entry = findByEntryUuid(id);
+      if (entry == null) {
+        return null;
+      }
+      return attributeMapper.toScimUser(entry);
+    } catch (ResourceException e) {
+      throw e;
+    } catch (Exception e) {
+      LOG.error("Failed to get user", e);
+      throw new ResourceException(500, "Failed to get user");
+    }
+  }
+
+  /**
+   * Deletes a SCIM user from LDAP.
+   *
+   * <p>Resolves the SCIM {@code id} to an LDAP entry via an {@code entryUUID} search,
+   * then deletes the entry by its distinguished name.</p>
+   *
+   * @param id the SCIM resource id (LDAP {@code entryUUID})
+   * @throws ResourceNotFoundException if no entry with the given {@code entryUUID} exists
+   * @throws ResourceException         if the LDAP operation fails
+   */
+  @Override
+  public void delete(String id) throws ResourceException {
+    Entry entry = findByEntryUuid(id);
+    if (entry == null) {
+      throw new ResourceNotFoundException(id);
+    }
+    ldapDao.delete(entry.getDn().toString());
+  }
+
+  /**
+   * Searches for SCIM users matching the given filter.
+   *
+   * <p>Delegates to {@link LdapDao#findUsers(Filter, PageRequest)} which translates the SCIM
+   * filter, performs a paged LDAP search under the user base DN, and applies SCIM pagination.
+   * The returned entries are mapped to {@link ScimUser} instances.</p>
+   *
+   * @param filter         the SCIM filter to apply, or {@code null} for all users
+   * @param requestContext the current SCIM request context (includes pagination parameters)
+   * @return a {@link FilterResponse} containing the matching users and total count
+   * @throws ResourceException if the LDAP search or mapping fails
+   */
+  @Override
+  public FilterResponse<ScimUser> find(Filter filter, ScimRequestContext requestContext) throws ResourceException {
+    try {
+      // LdapDao handles LDAP paged results control and SCIM pagination
+      PageRequest pageRequest = requestContext.getPageRequest().orElse(null);
+      FilterResponse<Entry> ldapResults = ldapDao.findUsers(filter, pageRequest);
+
+      // Map LDAP entries to SCIM users
+      List<ScimUser> users = new ArrayList<>();
+      for (Entry entry : ldapResults.getResources()) {
+        try {
+          users.add(attributeMapper.toScimUser(entry));
+        } catch (Exception e) {
+          LOG.error("Failed to map LDAP entry to ScimUser: {}", entry.getDn(), e);
+          throw new ResourceException(500, "Failed to map LDAP entry: " + entry.getDn());
+        }
+      }
+
+      return new FilterResponse<>(users, ldapResults.getTotalResults());
+    } catch (ResourceException e) {
+      throw e;
+    } catch (Exception e) {
+      LOG.error("Failed to search users", e);
+      throw new ResourceException(500, "Failed to search users");
+    }
+  }
+
+  private Entry findByEntryUuid(String entryUuid) throws ResourceException {
+    return ldapDao.searchByAttribute(properties.getUserBaseDn(), "entryUUID", entryUuid);
+  }
+
+  private List<Modification> buildReplaceModifications(Entry updated) {
+    List<Modification> modifications = new ArrayList<>();
+
+    for (Attribute attr : updated) {
+      String attrId = attr.getId();
+      // Skip objectClass and DN-related attributes
+      if ("objectClass".equalsIgnoreCase(attrId) || attributeMapper.getUserRdnAttribute().equalsIgnoreCase(attrId)) {
+        continue;
+      }
+      modifications.add(new DefaultModification(ModificationOperation.REPLACE_ATTRIBUTE, attr));
+    }
+
+    return modifications;
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/resources/beans.xml b/reference-projects/scim-server-ldap/src/main/resources/beans.xml
new file mode 100644
index 0000000..c5985a0
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/resources/beans.xml
@@ -0,0 +1,22 @@
+<!--  Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License. -->
+<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
+       version="4.0" bean-discovery-mode="annotated">
+
+</beans>
diff --git a/reference-projects/scim-server-ldap/src/main/resources/logback.xml b/reference-projects/scim-server-ldap/src/main/resources/logback.xml
new file mode 100644
index 0000000..0731c9a
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/resources/logback.xml
@@ -0,0 +1,26 @@
+<!--  Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License. -->
+<configuration>
+  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+    <encoder>
+      <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+    </encoder>
+  </appender>
+  <root level="INFO">
+    <appender-ref ref="STDOUT" />
+  </root>
+</configuration>
diff --git a/reference-projects/scim-server-ldap/src/main/resources/scim-ldap.yml b/reference-projects/scim-server-ldap/src/main/resources/scim-ldap.yml
new file mode 100644
index 0000000..0a09faf
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/resources/scim-ldap.yml
@@ -0,0 +1,79 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#  http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+# SCIMple LDAP Server Configuration
+#
+# This file configures the LDAP-backed SCIM server reference implementation.
+# LDAP connection settings can be overridden with system properties:
+#   -Dldap.host=myserver -Dldap.port=636 -Dldap.bind.dn=cn=scim,dc=corp ...
+#
+# WARNING: The default values below are for development only.
+# Do NOT use them in production. Override bind credentials and enable TLS.
+
+# LDAP server connection settings
+# Override with system properties: -Dldap.host=..., -Dldap.port=..., etc.
+ldap:
+  embedded: false        # Set to true (or -Dldap.embedded=true) to start an in-memory ApacheDS
+                         # on an ephemeral port; host/port/bind settings below are for external LDAP only
+  host: localhost        # LDAP server hostname
+  port: 389              # Use 636 for LDAPS
+  bindDn: cn=admin,dc=example,dc=com
+  bindPassword: secret   # WARNING: development only — use -Dldap.bind.password for production
+  useTls: false          # Set to true for production; credentials are sent in plaintext without TLS
+  userBaseDn: ou=users,dc=example,dc=com    # Base DN for SCIM User searches
+  groupBaseDn: ou=groups,dc=example,dc=com  # Base DN for SCIM Group searches
+
+# SCIM User <-> LDAP inetOrgPerson attribute mapping
+# Maps SCIM User attributes (left) to LDAP attributes (right).
+# The "objectClasses" list defines which LDAP object classes are used when creating entries.
+user:
+  objectClasses:         # LDAP object classes applied to new user entries
+    - inetOrgPerson
+    - organizationalPerson
+    - person
+    - extensibleObject    # Allows custom SCIM attributes (scimActive, scimPhoneTypes) without schema changes
+    - top
+  rdnAttribute: uid      # LDAP attribute used as the RDN (Relative Distinguished Name)
+  attributes:
+    # SCIM attribute path  : LDAP attribute name
+    userName: uid
+    "name.givenName": givenName
+    "name.familyName": sn
+    "name.formatted": cn
+    displayName: displayName
+    "emails.value": mail
+    "phoneNumbers.value": telephoneNumber
+    "addresses.streetAddress": street
+    "addresses.locality": l           # LDAP "l" = locality (city)
+    "addresses.postalCode": postalCode
+    title: title
+    userType: employeeType
+    password: userPassword            # Stored as an LDAP hashed password
+
+# SCIM Group <-> LDAP groupOfNames attribute mapping
+# Maps SCIM Group attributes (left) to LDAP attributes (right).
+group:
+  objectClasses:         # LDAP object classes applied to new group entries
+    - groupOfNames
+    - top
+  rdnAttribute: cn       # Groups are identified by their common name
+  attributes:
+    # SCIM attribute path  : LDAP attribute name
+    displayName: cn
+    "members.value": member           # Each member value is a full DN of the user entry
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/LdapTestServer.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/LdapTestServer.java
new file mode 100644
index 0000000..e2e176e
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/LdapTestServer.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+
+ * http://www.apache.org/licenses/LICENSE-2.0
+
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.directory.scim.ldap;
+
+import jakarta.annotation.Priority;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Alternative;
+import jakarta.enterprise.inject.se.SeContainer;
+import jakarta.enterprise.inject.se.SeContainerInitializer;
+import jakarta.ws.rs.SeBootstrap;
+import jakarta.ws.rs.core.UriBuilder;
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.schema.AttributeType;
+import org.apache.directory.api.ldap.model.schema.SchemaManager;
+import org.apache.directory.scim.compliance.junit.EmbeddedServerExtension;
+import org.apache.directory.scim.ldap.ldap.EmbeddedLdapServer;
+import org.apache.directory.scim.ldap.ldap.LdapConnectionManager;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.ldap.mapping.FilterTranslator;
+import org.apache.directory.scim.ldap.service.LdapGroupRepository;
+import org.apache.directory.scim.ldap.service.LdapUserRepository;
+
+import java.net.URI;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Compliance test harness that starts an embedded Apache DS LDAP server and a SCIM server
+ * for running the SCIMple compliance test suite. Registered via {@code META-INF/services} SPI
+ * as an {@link EmbeddedServerExtension.ScimTestServer} implementation.
+ *
+ * <p>Extends {@link EmbeddedLdapServer} and overrides {@link #seedSampleData(ScimLdapConfig)}
+ * to register custom schema attributes and seed test-specific entries instead of the default
+ * demo data.</p>
+ */
+public class LdapTestServer extends EmbeddedLdapServer
+    implements EmbeddedServerExtension.ScimTestServer {
+
+  private SeContainer container;
+  private SeBootstrap.Instance server;
+
+  /**
+   * CDI {@link Alternative} that overrides {@link ScimLdapConfig} to provide the dynamic LDAP
+   * port assigned to the embedded test server. All other configuration values are inherited
+   * from the test {@code scim-ldap.yml} resource.
+   */
+  @Alternative
+  @Priority(1)
+  @ApplicationScoped
+  public static class TestScimLdapConfig extends ScimLdapConfig {
+    private static volatile int ldapPort;
+
+    /**
+     * Sets the LDAP port that {@link #getPort()} will return.
+     *
+     * @param port the port the embedded LDAP server is listening on
+     */
+    static void configure(int port) {
+      ldapPort = port;
+    }
+
+    /**
+     * Returns the dynamic LDAP port for the embedded test server.
+     *
+     * @return the port the embedded LDAP server is listening on
+     */
+    @Override
+    public int getPort() { return ldapPort; }
+  }
+
+  /**
+   * Seeds test-specific entries instead of the default sample data.
+   *
+   * <p>Registers custom schema attributes ({@code scimActive}, {@code scimPhoneTypes}) and
+   * adds a test user and group used by the compliance test suite.</p>
+   *
+   * @param config the SCIM-LDAP configuration providing base DNs
+   * @throws Exception if entries cannot be created
+   */
+  @Override
+  protected void seedSampleData(ScimLdapConfig config) throws Exception {
+    SchemaManager schemaManager = getDirectoryService().getSchemaManager();
+
+    // Register custom attribute types used by AttributeMapper
+    registerCustomAttribute(schemaManager, "1.3.6.1.4.1.99999.1.1", "scimActive");
+    registerCustomAttribute(schemaManager, "1.3.6.1.4.1.99999.1.2", "scimPhoneTypes");
+
+    var session = getDirectoryService().getAdminSession();
+    String userBaseDn = config.getUserBaseDn();
+    String groupBaseDn = config.getGroupBaseDn();
+
+    // Add a default test user
+    session.add(new DefaultEntry(schemaManager,
+      "uid=testuser," + userBaseDn,
+      "objectClass: inetOrgPerson",
+      "objectClass: organizationalPerson",
+      "objectClass: person",
+      "objectClass: top",
+      "uid: testuser",
+      "cn: Test McTest",
+      "sn: McTest",
+      "givenName: Test",
+      "displayName: Test McTest",
+      "mail: test@example.com"));
+
+    // Add a default test group
+    session.add(new DefaultEntry(schemaManager,
+      "cn=example-group," + groupBaseDn,
+      "objectClass: groupOfNames",
+      "objectClass: top",
+      "cn: example-group",
+      "member: uid=testuser," + userBaseDn));
+  }
+
+  /**
+   * Starts an embedded Apache DS instance, seeds it with test data (users and groups),
+   * then starts a CDI container and JAX-RS SCIM server on the given port.
+   *
+   * @param port the HTTP port the SCIM server should bind to
+   * @return the base URI of the running SCIM server
+   * @throws Exception if any server fails to start or test data cannot be loaded
+   */
+  @Override
+  public URI start(int port) throws Exception {
+
+    // Start embedded LDAP server with test-specific seed data
+    ScimLdapConfig ldapConfig = new ScimLdapConfig(
+      "127.0.0.1", 0, "uid=admin,ou=system", "secret",
+      "ou=users,dc=example,dc=com", "ou=groups,dc=example,dc=com", false);
+    super.start(ldapConfig);
+
+    // Configure test LDAP properties via CDI alternative (no system properties)
+    TestScimLdapConfig.configure(getPort());
+
+    // Start CDI container and SCIM server
+    // Register CDI beans explicitly instead of using addPackages() — Spring Boot's nested
+    // JAR classloader uses URLs that Weld SE's package scanner cannot enumerate.
+    container = SeContainerInitializer.newInstance()
+      .addBeanClasses(
+        LdapApplication.class,
+        ScimLdapConfig.class,
+        LdapConnectionManager.class,
+        LdapDao.class,
+        AttributeMapper.class,
+        FilterTranslator.class,
+        LdapUserRepository.class,
+        LdapGroupRepository.class,
+        TestScimLdapConfig.class
+      )
+      .initialize();
+
+    LdapApplication app = new LdapApplication();
+    server = SeBootstrap.start(app, SeBootstrap.Configuration.builder().port(port).build())
+      .toCompletableFuture().get(1, TimeUnit.MINUTES);
+
+    server.stopOnShutdown(stopResult -> container.close());
+
+    return UriBuilder.fromUri("http://localhost/").port(port).build();
+  }
+
+  /**
+   * Stops the SCIM server, CDI container, and embedded LDAP server, in that order.
+   * Each component is stopped only if it was previously started.
+   *
+   * @throws Exception if any server fails to stop cleanly
+   */
+  @Override
+  public void shutdown() throws Exception {
+    if (server != null) {
+      server.stop().toCompletableFuture()
+        .get(10, TimeUnit.SECONDS);
+    }
+
+    if (container != null) {
+      container.close();
+    }
+
+    super.stop();
+  }
+
+  private static void registerCustomAttribute(SchemaManager schemaManager, String oid, String name)
+    throws Exception {
+    AttributeType attrType = new AttributeType(oid);
+    attrType.setNames(name);
+    attrType.setSyntaxOid("1.3.6.1.4.1.1466.115.121.1.15"); // Directory String
+    attrType.setEqualityOid("2.5.13.2"); // caseIgnoreMatch
+    attrType.setSingleValued(true);
+    schemaManager.add(attrType);
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServerTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServerTest.java
new file mode 100644
index 0000000..aa40755
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServerTest.java
@@ -0,0 +1,78 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.apache.directory.ldap.client.api.LdapNetworkConnection;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link EmbeddedLdapServer}.
+ */
+class EmbeddedLdapServerTest {
+
+  @Test
+  void startsAndSeedsBaseEntriesAndSampleData() throws Exception {
+    ScimLdapConfig config = new ScimLdapConfig(
+      "127.0.0.1", 389, "uid=admin,ou=system", "secret",
+      "ou=users,dc=example,dc=com", "ou=groups,dc=example,dc=com", false);
+    config.init();
+
+    EmbeddedLdapServer server = new EmbeddedLdapServer();
+    try {
+      server.start(config);
+
+      assertThat(server.getHost()).isEqualTo("127.0.0.1");
+      assertThat(server.getPort()).isGreaterThan(0);
+
+      // Connect and verify base entries and sample data exist
+      try (LdapConnection conn = new LdapNetworkConnection(server.getHost(), server.getPort())) {
+        conn.bind("uid=admin,ou=system", "secret");
+
+        // Base entries
+        assertThat(conn.exists("dc=example,dc=com")).isTrue();
+        assertThat(conn.exists("ou=users,dc=example,dc=com")).isTrue();
+        assertThat(conn.exists("ou=groups,dc=example,dc=com")).isTrue();
+
+        // Sample users
+        assertThat(conn.exists("uid=bjensen,ou=users,dc=example,dc=com")).isTrue();
+        assertThat(conn.exists("uid=jsmith,ou=users,dc=example,dc=com")).isTrue();
+        assertThat(conn.exists("uid=awhite,ou=users,dc=example,dc=com")).isTrue();
+
+        // Sample group
+        assertThat(conn.exists("cn=Engineering,ou=groups,dc=example,dc=com")).isTrue();
+
+        conn.unBind();
+      }
+    } finally {
+      server.stop();
+    }
+  }
+
+  @Test
+  void stopIsIdempotent() {
+    EmbeddedLdapServer server = new EmbeddedLdapServer();
+    // Calling stop without start should not throw
+    server.stop();
+    server.stop();
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/LdapDaoTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/LdapDaoTest.java
new file mode 100644
index 0000000..074f8bb
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/LdapDaoTest.java
@@ -0,0 +1,315 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import org.apache.directory.api.ldap.model.cursor.SearchCursor;
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.DefaultModification;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.api.ldap.model.entry.ModificationOperation;
+import org.apache.directory.api.ldap.model.exception.LdapEntryAlreadyExistsException;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapNoSuchObjectException;
+import org.apache.directory.api.ldap.model.filter.ExprNode;
+import org.apache.directory.api.ldap.model.message.SearchRequest;
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.apache.directory.scim.ldap.mapping.FilterTranslator;
+import org.apache.directory.scim.spec.exception.ConflictResourceException;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LdapDaoTest {
+
+  // =========================================================================
+  // CRUD and search methods (mock LdapConnectionManager)
+  // =========================================================================
+
+  @Nested
+  @DisplayName("CRUD operations")
+  class CrudTest {
+
+    LdapConnectionManager connectionManager = mock(LdapConnectionManager.class);
+    FilterTranslator filterTranslator = mock(FilterTranslator.class);
+    ScimLdapConfig config = mock(ScimLdapConfig.class);
+    LdapConnection conn = mock(LdapConnection.class);
+    LdapDao dao;
+
+    @BeforeEach
+    void setUp() throws Exception {
+      dao = new LdapDao();
+      setField(dao, "connectionManager", connectionManager);
+      setField(dao, "filterTranslator", filterTranslator);
+      setField(dao, "config", config);
+
+      when(connectionManager.getConnection()).thenReturn(conn);
+      when(config.getUserBaseDn()).thenReturn("ou=users,dc=example,dc=com");
+      when(config.getGroupBaseDn()).thenReturn("ou=groups,dc=example,dc=com");
+    }
+
+    // ----- create -----
+
+    @Test
+    void createSuccess() throws Exception {
+      Entry entry = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+      dao.create(entry);
+      verify(conn).add(entry);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void createDuplicateThrowsConflict() throws Exception {
+      Entry entry = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+      doThrow(new LdapEntryAlreadyExistsException()).when(conn).add(entry);
+      assertThatThrownBy(() -> dao.create(entry))
+        .isInstanceOf(ConflictResourceException.class);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void createGenericLdapExceptionThrows500() throws Exception {
+      Entry entry = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+      doThrow(new LdapException("fail")).when(conn).add(entry);
+      assertThatThrownBy(() -> dao.create(entry))
+        .isInstanceOf(ResourceException.class)
+        .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(500));
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void createAlwaysReleasesConnection() throws Exception {
+      Entry entry = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+      doThrow(new LdapException("fail")).when(conn).add(entry);
+      try { dao.create(entry); } catch (ResourceException ignored) {}
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    // ----- lookup -----
+
+    @Test
+    void lookupSuccess() throws Exception {
+      String dn = "uid=test,ou=users,dc=example,dc=com";
+      Entry expected = new DefaultEntry(dn);
+      when(conn.lookup(dn, "*", "+")).thenReturn(expected);
+      assertThat(dao.lookup(dn)).isSameAs(expected);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void lookupNullThrowsNotFound() throws Exception {
+      String dn = "uid=missing,ou=users,dc=example,dc=com";
+      when(conn.lookup(dn, "*", "+")).thenReturn(null);
+      assertThatThrownBy(() -> dao.lookup(dn))
+        .isInstanceOf(ResourceNotFoundException.class);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void lookupNoSuchObjectThrowsNotFound() throws Exception {
+      String dn = "uid=missing,ou=users,dc=example,dc=com";
+      when(conn.lookup(dn, "*", "+")).thenThrow(new LdapNoSuchObjectException());
+      assertThatThrownBy(() -> dao.lookup(dn))
+        .isInstanceOf(ResourceNotFoundException.class);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    // ----- searchByAttribute -----
+
+    @Test
+    void searchByAttributeFound() throws Exception {
+      Entry expected = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+
+      SearchCursor cursor = mock(SearchCursor.class);
+      when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+      when(cursor.next()).thenReturn(true, false);
+      when(cursor.isEntry()).thenReturn(true);
+      when(cursor.getEntry()).thenReturn(expected);
+
+      assertThat(dao.searchByAttribute("ou=users,dc=example,dc=com", "uid", "testuser"))
+        .isSameAs(expected);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void searchByAttributeNotFound() throws Exception {
+      SearchCursor cursor = mock(SearchCursor.class);
+      when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+      when(cursor.next()).thenReturn(false);
+
+      assertThat(dao.searchByAttribute("ou=users,dc=example,dc=com", "uid", "nobody"))
+        .isNull();
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    // ----- findUsers -----
+
+    @Test
+    void findUsersReturnsEntries() throws Exception {
+      ExprNode mockFilter = mock(ExprNode.class);
+      when(filterTranslator.buildUserSearchFilter(any())).thenReturn(mockFilter);
+
+      Entry entry1 = new DefaultEntry("uid=alice,ou=users,dc=example,dc=com");
+      Entry entry2 = new DefaultEntry("uid=bob,ou=users,dc=example,dc=com");
+
+      SearchCursor cursor = mock(SearchCursor.class);
+      when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+      when(cursor.next()).thenReturn(true, true, false);
+      when(cursor.isEntry()).thenReturn(true, true);
+      when(cursor.getEntry()).thenReturn(entry1, entry2);
+      when(cursor.getSearchResultDone()).thenReturn(null);
+
+      FilterResponse<Entry> result = dao.findUsers((Filter) null, null);
+      assertThat(result.getResources()).containsExactly(entry1, entry2);
+      assertThat(result.getTotalResults()).isEqualTo(2);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void findUsersEmptyReturnsEmptyList() throws Exception {
+      ExprNode mockFilter = mock(ExprNode.class);
+      when(filterTranslator.buildUserSearchFilter(any())).thenReturn(mockFilter);
+
+      SearchCursor cursor = mock(SearchCursor.class);
+      when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+      when(cursor.next()).thenReturn(false);
+      when(cursor.getSearchResultDone()).thenReturn(null);
+
+      FilterResponse<Entry> result = dao.findUsers((Filter) null, null);
+      assertThat(result.getResources()).isEmpty();
+      assertThat(result.getTotalResults()).isEqualTo(0);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    // ----- findGroups -----
+
+    @Test
+    void findGroupsReturnsEntries() throws Exception {
+      ExprNode mockFilter = mock(ExprNode.class);
+      when(filterTranslator.buildGroupSearchFilter(any())).thenReturn(mockFilter);
+
+      Entry entry1 = new DefaultEntry("cn=admins,ou=groups,dc=example,dc=com");
+
+      SearchCursor cursor = mock(SearchCursor.class);
+      when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+      when(cursor.next()).thenReturn(true, false);
+      when(cursor.isEntry()).thenReturn(true);
+      when(cursor.getEntry()).thenReturn(entry1);
+      when(cursor.getSearchResultDone()).thenReturn(null);
+
+      FilterResponse<Entry> result = dao.findGroups((Filter) null, null);
+      assertThat(result.getResources()).containsExactly(entry1);
+      assertThat(result.getTotalResults()).isEqualTo(1);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    // ----- modify -----
+
+    @Test
+    void modifySuccess() throws Exception {
+      String dn = "uid=test,ou=users,dc=example,dc=com";
+      Modification mod = new DefaultModification(ModificationOperation.REPLACE_ATTRIBUTE, "cn", "New Name");
+      dao.modify(dn, mod);
+      verify(conn).modify(dn, mod);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void modifyNoSuchObjectThrowsNotFound() throws Exception {
+      String dn = "uid=missing,ou=users,dc=example,dc=com";
+      Modification mod = new DefaultModification(ModificationOperation.REPLACE_ATTRIBUTE, "cn", "New Name");
+      doThrow(new LdapNoSuchObjectException()).when(conn).modify(dn, mod);
+      assertThatThrownBy(() -> dao.modify(dn, mod))
+        .isInstanceOf(ResourceNotFoundException.class);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    // ----- delete -----
+
+    @Test
+    void deleteSuccess() throws Exception {
+      String dn = "uid=test,ou=users,dc=example,dc=com";
+      dao.delete(dn);
+      verify(conn).delete(dn);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void deleteNoSuchObjectThrowsNotFound() throws Exception {
+      String dn = "uid=missing,ou=users,dc=example,dc=com";
+      doThrow(new LdapNoSuchObjectException()).when(conn).delete(dn);
+      assertThatThrownBy(() -> dao.delete(dn))
+        .isInstanceOf(ResourceNotFoundException.class);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    // ----- rename -----
+
+    @Test
+    void rename_delegatesToConnectionRename() throws Exception {
+      String dn = "uid=old,ou=users,dc=example,dc=com";
+      String newRdn = "uid=new";
+      dao.rename(dn, newRdn);
+      verify(conn).rename(dn, newRdn, true);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void rename_entryNotFound_throwsResourceNotFoundException() throws Exception {
+      String dn = "uid=missing,ou=users,dc=example,dc=com";
+      doThrow(new LdapNoSuchObjectException()).when(conn).rename(anyString(), anyString(), anyBoolean());
+      assertThatThrownBy(() -> dao.rename(dn, "uid=new"))
+        .isInstanceOf(ResourceNotFoundException.class);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    @Test
+    void rename_entryAlreadyExists_throwsConflictResourceException() throws Exception {
+      String dn = "uid=old,ou=users,dc=example,dc=com";
+      doThrow(new LdapEntryAlreadyExistsException()).when(conn).rename(anyString(), anyString(), anyBoolean());
+      assertThatThrownBy(() -> dao.rename(dn, "uid=existing"))
+        .isInstanceOf(ConflictResourceException.class);
+      verify(connectionManager).releaseConnection(conn);
+    }
+
+    private void setField(Object target, String fieldName, Object value) throws Exception {
+      Field field = target.getClass().getDeclaredField(fieldName);
+      field.setAccessible(true);
+      field.set(target, value);
+    }
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfigTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfigTest.java
new file mode 100644
index 0000000..5f5a5a5
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfigTest.java
@@ -0,0 +1,200 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@Execution(ExecutionMode.SAME_THREAD)
+class ScimLdapConfigTest {
+
+  private static final List<String> SYSTEM_PROPERTY_KEYS = List.of(
+    "ldap.host", "ldap.port", "ldap.bind.dn", "ldap.bind.password",
+    "ldap.base.dn.users", "ldap.base.dn.groups", "ldap.use.tls"
+  );
+
+  @AfterEach
+  void clearSystemProperties() {
+    SYSTEM_PROPERTY_KEYS.forEach(System::clearProperty);
+  }
+
+  // Helper: create an instance via the protected no-arg constructor and call init()
+  private static ScimLdapConfig createAndInit() throws Exception {
+    Constructor<ScimLdapConfig> ctor = ScimLdapConfig.class.getDeclaredConstructor();
+    ctor.setAccessible(true);
+    ScimLdapConfig config = ctor.newInstance();
+
+    Method init = ScimLdapConfig.class.getDeclaredMethod("init");
+    init.setAccessible(true);
+    init.invoke(config);
+    return config;
+  }
+
+  // =========================================================================
+  // Group 1: Programmatic constructor
+  // =========================================================================
+
+  @Nested
+  @DisplayName("Programmatic constructor")
+  class ProgrammaticConstructorTest {
+
+    private final ScimLdapConfig config = new ScimLdapConfig(
+      "ldap.example.org", 636, "cn=admin,dc=test,dc=com", "p@ss",
+      "ou=people,dc=test,dc=com", "ou=roles,dc=test,dc=com", true
+    );
+
+    @Test
+    void hostIsSet() {
+      assertThat(config.getHost()).isEqualTo("ldap.example.org");
+    }
+
+    @Test
+    void portIsSet() {
+      assertThat(config.getPort()).isEqualTo(636);
+    }
+
+    @Test
+    void bindDnIsSet() {
+      assertThat(config.getBindDn()).isEqualTo("cn=admin,dc=test,dc=com");
+    }
+
+    @Test
+    void bindPasswordIsSet() {
+      assertThat(config.getBindPassword()).isEqualTo("p@ss");
+    }
+
+    @Test
+    void userBaseDnIsSet() {
+      assertThat(config.getUserBaseDn()).isEqualTo("ou=people,dc=test,dc=com");
+    }
+
+    @Test
+    void groupBaseDnIsSet() {
+      assertThat(config.getGroupBaseDn()).isEqualTo("ou=roles,dc=test,dc=com");
+    }
+
+    @Test
+    void useTlsIsSet() {
+      assertThat(config.isUseTls()).isTrue();
+    }
+  }
+
+  // =========================================================================
+  // Group 2: init() loading from test classpath YAML
+  // =========================================================================
+
+  @Nested
+  @DisplayName("init() with test classpath YAML")
+  class InitFromYamlTest {
+
+    @Test
+    void loadsLdapConnectionSettings() throws Exception {
+      ScimLdapConfig config = createAndInit();
+
+      assertThat(config.getHost()).isEqualTo("127.0.0.1");
+      assertThat(config.getPort()).isEqualTo(10389);
+      assertThat(config.getBindDn()).isEqualTo("uid=admin,ou=system");
+      assertThat(config.getBindPassword()).isEqualTo("secret");
+      assertThat(config.getUserBaseDn()).isEqualTo("ou=users,dc=example,dc=com");
+      assertThat(config.getGroupBaseDn()).isEqualTo("ou=groups,dc=example,dc=com");
+      assertThat(config.isUseTls()).isFalse();
+    }
+
+    @Test
+    void loadsUserMapping() throws Exception {
+      ScimLdapConfig config = createAndInit();
+
+      assertThat(config.getUserObjectClasses()).contains("extensibleObject", "inetOrgPerson", "top");
+      assertThat(config.getUserRdnAttribute()).isEqualTo("uid");
+      assertThat(config.getUserAttributes())
+        .containsEntry("userName", "uid")
+        .containsEntry("name.givenName", "givenName")
+        .containsEntry("emails.value", "mail");
+    }
+
+    @Test
+    void loadsGroupMapping() throws Exception {
+      ScimLdapConfig config = createAndInit();
+
+      assertThat(config.getGroupObjectClasses()).containsExactly("groupOfNames", "top");
+      assertThat(config.getGroupRdnAttribute()).isEqualTo("cn");
+      assertThat(config.getGroupAttributes())
+        .containsEntry("displayName", "cn")
+        .containsEntry("members.value", "member");
+    }
+  }
+
+  // =========================================================================
+  // Group 3: System property overrides
+  // =========================================================================
+
+  @Nested
+  @DisplayName("System property overrides")
+  class SystemPropertyOverrideTest {
+
+    @Test
+    void systemPropertyOverridesHost() throws Exception {
+      System.setProperty("ldap.host", "override.example.com");
+      ScimLdapConfig config = createAndInit();
+      assertThat(config.getHost()).isEqualTo("override.example.com");
+    }
+
+    @Test
+    void systemPropertyOverridesPort() throws Exception {
+      System.setProperty("ldap.port", "1636");
+      ScimLdapConfig config = createAndInit();
+      assertThat(config.getPort()).isEqualTo(1636);
+    }
+
+    @Test
+    void systemPropertyOverridesBindDn() throws Exception {
+      System.setProperty("ldap.bind.dn", "cn=override,dc=test,dc=com");
+      ScimLdapConfig config = createAndInit();
+      assertThat(config.getBindDn()).isEqualTo("cn=override,dc=test,dc=com");
+    }
+
+    @Test
+    void systemPropertyOverridesUseTls() throws Exception {
+      System.setProperty("ldap.use.tls", "true");
+      ScimLdapConfig config = createAndInit();
+      assertThat(config.isUseTls()).isTrue();
+    }
+
+    @Test
+    void invalidPortValueThrowsIllegalArgumentException() {
+      System.setProperty("ldap.port", "not-a-number");
+      assertThatThrownBy(ScimLdapConfigTest::createAndInit)
+        .isInstanceOf(InvocationTargetException.class)
+        .hasCauseInstanceOf(IllegalArgumentException.class);
+    }
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/AttributeMapperTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/AttributeMapperTest.java
new file mode 100644
index 0000000..6ffe705
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/AttributeMapperTest.java
@@ -0,0 +1,791 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.mapping;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapInvalidAttributeValueException;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.spec.resources.Address;
+import org.apache.directory.scim.spec.resources.Email;
+import org.apache.directory.scim.spec.resources.GroupMembership;
+import org.apache.directory.scim.spec.resources.Name;
+import org.apache.directory.scim.spec.resources.PhoneNumber;
+import org.apache.directory.scim.spec.resources.ScimGroup;
+import org.apache.directory.scim.spec.resources.ScimUser;
+import org.apache.directory.scim.spec.schema.Meta;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class AttributeMapperTest {
+
+  static final String USER_BASE_DN = "ou=users,dc=example,dc=com";
+  static final String GROUP_BASE_DN = "ou=groups,dc=example,dc=com";
+
+  AttributeMapper mapper;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    ScimLdapConfig config = mock(ScimLdapConfig.class);
+
+    when(config.getUserObjectClasses()).thenReturn(
+      List.of("inetOrgPerson", "organizationalPerson", "person", "top"));
+    when(config.getUserRdnAttribute()).thenReturn("uid");
+    when(config.getUserAttributes()).thenReturn(defaultUserAttributes());
+
+    when(config.getGroupObjectClasses()).thenReturn(List.of("groupOfNames", "top"));
+    when(config.getGroupRdnAttribute()).thenReturn("cn");
+    when(config.getGroupAttributes()).thenReturn(defaultGroupAttributes());
+
+    mapper = AttributeMapper.class.getDeclaredConstructor().newInstance();
+
+    Field propertiesField = AttributeMapper.class.getDeclaredField("properties");
+    propertiesField.setAccessible(true);
+    propertiesField.set(mapper, config);
+
+    Method initMethod = AttributeMapper.class.getDeclaredMethod("init");
+    initMethod.setAccessible(true);
+    initMethod.invoke(mapper);
+  }
+
+  // ── toScimUser ──────────────────────────────────────────────────────
+
+  @Test
+  void toScimUser_allFieldsPopulated() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "entryUUID: 550e8400-e29b-41d4-a716-446655440000",
+      "uid: jdoe",
+      "givenName: John",
+      "sn: Doe",
+      "cn: John Doe",
+      "displayName: Johnny Doe",
+      "mail: john@example.com",
+      "telephoneNumber: +1-555-1234",
+      "street: 123 Main St",
+      "l: Springfield",
+      "postalCode: 62701",
+      "title: Engineer",
+      "employeeType: Employee",
+      "scimActive: true"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getId()).isEqualTo("550e8400-e29b-41d4-a716-446655440000");
+    assertThat(user.getUserName()).isEqualTo("jdoe");
+    assertThat(user.getName()).isNotNull();
+    assertThat(user.getName().getGivenName()).isEqualTo("John");
+    assertThat(user.getName().getFamilyName()).isEqualTo("Doe");
+    assertThat(user.getName().getFormatted()).isEqualTo("John Doe");
+    assertThat(user.getDisplayName()).isEqualTo("Johnny Doe");
+    assertThat(user.getEmails()).hasSize(1);
+    assertThat(user.getEmails().get(0).getValue()).isEqualTo("john@example.com");
+    assertThat(user.getPhoneNumbers()).hasSize(1);
+    assertThat(user.getPhoneNumbers().get(0).getValue()).isEqualTo("+1-555-1234");
+    assertThat(user.getAddresses()).hasSize(1);
+    assertThat(user.getAddresses().get(0).getStreetAddress()).isEqualTo("123 Main St");
+    assertThat(user.getAddresses().get(0).getLocality()).isEqualTo("Springfield");
+    assertThat(user.getAddresses().get(0).getPostalCode()).isEqualTo("62701");
+    assertThat(user.getTitle()).isEqualTo("Engineer");
+    assertThat(user.getUserType()).isEqualTo("Employee");
+    assertThat(user.getActive()).isTrue();
+  }
+
+  @Test
+  void toScimUser_minimalEntry_onlyUidAndEntryUuid() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "entryUUID: abc-123",
+      "uid: jdoe"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getId()).isEqualTo("abc-123");
+    assertThat(user.getUserName()).isEqualTo("jdoe");
+    assertThat(user.getName()).isNull();
+    assertThat(user.getDisplayName()).isNull();
+    assertThat(user.getEmails()).isNull();
+    assertThat(user.getPhoneNumbers()).isNull();
+    assertThat(user.getAddresses()).isNull();
+    assertThat(user.getTitle()).isNull();
+    assertThat(user.getUserType()).isNull();
+    assertThat(user.getActive()).isTrue();
+  }
+
+  @Test
+  void toScimUser_noEntryUuid_idIsNull() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getId()).isNull();
+    assertThat(user.getUserName()).isEqualTo("jdoe");
+  }
+
+  @Test
+  void toScimUser_multipleEmails_firstIsPrimary() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe",
+      "mail: primary@example.com",
+      "mail: secondary@example.com"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getEmails()).hasSize(2);
+    assertThat(user.getEmails().get(0).getValue()).isEqualTo("primary@example.com");
+    assertThat(user.getEmails().get(0).getPrimary()).isTrue();
+    assertThat(user.getEmails().get(1).getPrimary()).isFalse();
+  }
+
+  @Test
+  void toScimUser_multiplePhones_firstIsPrimary() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe",
+      "telephoneNumber: +1-555-0001",
+      "telephoneNumber: +1-555-0002"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getPhoneNumbers()).hasSize(2);
+    assertThat(user.getPhoneNumbers().get(0).getPrimary()).isTrue();
+    assertThat(user.getPhoneNumbers().get(1).getPrimary()).isFalse();
+  }
+
+  @Test
+  void toScimUser_phoneTypesFromScimPhoneTypes() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe",
+      "telephoneNumber: +1-555-0001",
+      "telephoneNumber: +1-555-0002",
+      "scimPhoneTypes: work,mobile"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getPhoneNumbers()).hasSize(2);
+    assertThat(user.getPhoneNumbers().get(0).getType()).isEqualTo("work");
+    assertThat(user.getPhoneNumbers().get(1).getType()).isEqualTo("mobile");
+  }
+
+  @Test
+  void toScimUser_noScimActive_defaultsToTrue() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getActive()).isTrue();
+  }
+
+  @Test
+  void toScimUser_scimActiveFalse_activeIsFalse() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe",
+      "scimActive: false"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getActive()).isFalse();
+  }
+
+  @Test
+  void toScimUser_withBothTimestamps_populatesMetaVersionAndDates() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe",
+      "createTimestamp: 20240101120000Z",
+      "modifyTimestamp: 20240115143000Z"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    Meta meta = user.getMeta();
+    assertThat(meta).isNotNull();
+    assertThat(meta.getResourceType()).isEqualTo("User");
+    assertThat(meta.getVersion()).isEqualTo("W/\"20240115143000Z\"");
+    assertThat(meta.getCreated()).isNotNull();
+    assertThat(meta.getLastModified()).isNotNull();
+  }
+
+  @Test
+  void toScimUser_onlyCreateTimestamp_versionUsesCreateTimestamp() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe",
+      "createTimestamp: 20240101120000Z"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    Meta meta = user.getMeta();
+    assertThat(meta).isNotNull();
+    assertThat(meta.getVersion()).isEqualTo("W/\"20240101120000Z\"");
+    assertThat(meta.getCreated()).isNotNull();
+    assertThat(meta.getLastModified()).isNull();
+  }
+
+  @Test
+  void toScimUser_noTimestamps_metaHasResourceTypeOnly() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    Meta meta = user.getMeta();
+    assertThat(meta).isNotNull();
+    assertThat(meta.getResourceType()).isEqualTo("User");
+    assertThat(meta.getVersion()).isNull();
+    assertThat(meta.getCreated()).isNull();
+    assertThat(meta.getLastModified()).isNull();
+  }
+
+  // ── toEntry(ScimUser, baseDn) ───────────────────────────────────────
+
+  @Test
+  void toEntryUser_constructsDnCorrectly() throws LdapException {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    assertThat(entry.getDn().toString()).isEqualTo("uid=jdoe,ou=users,dc=example,dc=com");
+  }
+
+  @Test
+  void toEntryUser_setsObjectClasses() throws LdapException {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    assertThat(entry.get("objectClass")).isNotNull();
+    assertThat(entry.get("objectClass").size()).isEqualTo(4);
+  }
+
+  @Test
+  void toEntryUser_setsAllMappedAttributes() throws Exception {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+    user.setDisplayName("John Doe");
+    user.setName(new Name());
+    user.getName().setGivenName("John");
+    user.getName().setFamilyName("Doe");
+    user.getName().setFormatted("John Doe");
+    user.setTitle("Engineer");
+    user.setUserType("Employee");
+    user.setActive(false);
+
+    Email email = new Email();
+    email.setValue("john@example.com");
+    user.setEmails(List.of(email));
+
+    PhoneNumber phone = new PhoneNumber();
+    phone.setValue("555-1234");
+    phone.setType("work");
+    user.setPhoneNumbers(List.of(phone));
+
+    Address address = new Address();
+    address.setStreetAddress("123 Main St");
+    address.setLocality("Springfield");
+    address.setPostalCode("62701");
+    user.setAddresses(List.of(address));
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    assertThat(entry.get("uid").getString()).isEqualTo("jdoe");
+    assertThat(entry.get("givenName").getString()).isEqualTo("John");
+    assertThat(entry.get("sn").getString()).isEqualTo("Doe");
+    assertThat(entry.get("cn").getString()).isEqualTo("John Doe");
+    assertThat(entry.get("displayName").getString()).isEqualTo("John Doe");
+    assertThat(entry.get("mail").getString()).isEqualTo("john@example.com");
+    assertThat(entry.get("telephoneNumber").getString()).isEqualTo("555-1234");
+    assertThat(entry.get("street").getString()).isEqualTo("123 Main St");
+    assertThat(entry.get("l").getString()).isEqualTo("Springfield");
+    assertThat(entry.get("postalCode").getString()).isEqualTo("62701");
+    assertThat(entry.get("title").getString()).isEqualTo("Engineer");
+    assertThat(entry.get("employeeType").getString()).isEqualTo("Employee");
+    assertThat(entry.get("scimActive").getString()).isEqualTo("false");
+  }
+
+  @Test
+  void toEntryUser_defaultsSnToUserName_whenFamilyNameMissing() throws LdapException {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    assertThat(entry.get("sn").getString()).isEqualTo("jdoe");
+  }
+
+  @Test
+  void toEntryUser_defaultsCnToDisplayName_thenUserName() throws LdapException {
+    // When displayName is set, cn should be the displayName
+    ScimUser userWithDisplay = new ScimUser();
+    userWithDisplay.setUserName("jdoe");
+    userWithDisplay.setDisplayName("John Doe");
+
+    Entry entryWithDisplay = mapper.toEntry(userWithDisplay, USER_BASE_DN);
+    // cn is set as the name.formatted mapping AND as the defaulted cn (displayName takes precedence)
+    assertThat(entryWithDisplay.get("cn").getString()).isEqualTo("John Doe");
+
+    // When displayName is null, cn should default to userName
+    ScimUser userNoDisplay = new ScimUser();
+    userNoDisplay.setUserName("jdoe");
+
+    Entry entryNoDisplay = mapper.toEntry(userNoDisplay, USER_BASE_DN);
+    assertThat(entryNoDisplay.get("cn").getString()).isEqualTo("jdoe");
+  }
+
+  @Test
+  void toEntryUser_nullUserName_throwsIllegalArgument() {
+    ScimUser user = new ScimUser();
+    user.setUserName(null);
+
+    assertThatThrownBy(() -> mapper.toEntry(user, USER_BASE_DN))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessageContaining("userName is required");
+  }
+
+  @Test
+  void toEntryUser_blankUserName_throwsIllegalArgument() {
+    ScimUser user = new ScimUser();
+    user.setUserName("   ");
+
+    assertThatThrownBy(() -> mapper.toEntry(user, USER_BASE_DN))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessageContaining("userName is required");
+  }
+
+  // ── toScimGroup ─────────────────────────────────────────────────────
+
+  @Test
+  void toScimGroup_allFieldsPopulated() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "cn=admins,ou=groups,dc=example,dc=com",
+      "entryUUID: group-uuid-1",
+      "cn: Admins",
+      "member: uid=jdoe,ou=users,dc=example,dc=com",
+      "member: uid=asmith,ou=users,dc=example,dc=com"
+    );
+
+    ScimGroup group = mapper.toScimGroup(entry);
+
+    assertThat(group.getId()).isEqualTo("group-uuid-1");
+    assertThat(group.getDisplayName()).isEqualTo("Admins");
+    assertThat(group.getMembers()).hasSize(2);
+    assertThat(group.getMembers().get(0).getValue())
+      .isEqualTo("uid=jdoe,ou=users,dc=example,dc=com");
+    assertThat(group.getMembers().get(1).getValue())
+      .isEqualTo("uid=asmith,ou=users,dc=example,dc=com");
+  }
+
+  @Test
+  void toScimGroup_blankMemberPlaceholder_isSkipped() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "cn=empty,ou=groups,dc=example,dc=com",
+      "entryUUID: group-uuid-2",
+      "cn: Empty Group",
+      "member: ",
+      "member: uid=jdoe,ou=users,dc=example,dc=com"
+    );
+
+    ScimGroup group = mapper.toScimGroup(entry);
+
+    assertThat(group.getMembers()).hasSize(1);
+    assertThat(group.getMembers().get(0).getValue())
+      .isEqualTo("uid=jdoe,ou=users,dc=example,dc=com");
+  }
+
+  @Test
+  void toScimGroup_noMembers_memberListIsNull() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "cn=lonely,ou=groups,dc=example,dc=com",
+      "entryUUID: group-uuid-3",
+      "cn: Lonely Group"
+    );
+
+    ScimGroup group = mapper.toScimGroup(entry);
+
+    assertThat(group.getMembers()).isNull();
+  }
+
+  // ── toEntry(ScimGroup, baseDn) ──────────────────────────────────────
+
+  @Test
+  void toEntryGroup_constructsDnCorrectly() throws LdapException {
+    ScimGroup group = new ScimGroup();
+    group.setDisplayName("Admins");
+
+    Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+    assertThat(entry.getDn().toString()).isEqualTo("cn=Admins,ou=groups,dc=example,dc=com");
+  }
+
+  @Test
+  void toEntryGroup_membersSet() throws LdapException {
+    ScimGroup group = new ScimGroup();
+    group.setDisplayName("Admins");
+
+    GroupMembership m1 = new GroupMembership();
+    m1.setValue("uid=jdoe,ou=users,dc=example,dc=com");
+    GroupMembership m2 = new GroupMembership();
+    m2.setValue("uid=asmith,ou=users,dc=example,dc=com");
+    group.setMembers(List.of(m1, m2));
+
+    Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+    assertThat(entry.get("member")).isNotNull();
+    assertThat(entry.get("member").size()).isEqualTo(2);
+  }
+
+  @Test
+  void toEntryGroup_noMembers_emptyPlaceholderAdded() throws LdapException {
+    ScimGroup group = new ScimGroup();
+    group.setDisplayName("Empty");
+
+    Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+    assertThat(entry.get("member")).isNotNull();
+    assertThat(entry.get("member").getString()).isEmpty();
+  }
+
+  @Test
+  void toEntryGroup_nullDisplayName_throwsIllegalArgument() {
+    ScimGroup group = new ScimGroup();
+    group.setDisplayName(null);
+
+    assertThatThrownBy(() -> mapper.toEntry(group, GROUP_BASE_DN))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessageContaining("displayName is required");
+  }
+
+  @Test
+  void toEntryGroup_blankDisplayName_throwsIllegalArgument() {
+    ScimGroup group = new ScimGroup();
+    group.setDisplayName("   ");
+
+    assertThatThrownBy(() -> mapper.toEntry(group, GROUP_BASE_DN))
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessageContaining("displayName is required");
+  }
+
+  // ── getLdapUserAttribute ────────────────────────────────────────────
+
+  @Test
+  void getLdapUserAttribute_mappedAttribute_returnsLdapName() {
+    assertThat(mapper.getLdapUserAttribute("userName")).isEqualTo("uid");
+  }
+
+  @Test
+  void getLdapUserAttribute_subAttribute_returnsLdapName() {
+    assertThat(mapper.getLdapUserAttribute("name.givenName")).isEqualTo("givenName");
+  }
+
+  @Test
+  void getLdapUserAttribute_unmappedAttribute_returnsNull() {
+    assertThat(mapper.getLdapUserAttribute("nonExistent")).isNull();
+  }
+
+  // ── getLdapGroupAttribute ───────────────────────────────────────────
+
+  @Test
+  void getLdapGroupAttribute_mappedAttribute_returnsLdapName() {
+    assertThat(mapper.getLdapGroupAttribute("displayName")).isEqualTo("cn");
+  }
+
+  @Test
+  void getLdapGroupAttribute_membersValue_returnsMember() {
+    assertThat(mapper.getLdapGroupAttribute("members.value")).isEqualTo("member");
+  }
+
+  @Test
+  void getLdapGroupAttribute_unmappedAttribute_returnsNull() {
+    assertThat(mapper.getLdapGroupAttribute("nonExistent")).isNull();
+  }
+
+  // ── Getters ─────────────────────────────────────────────────────────
+
+  @Test
+  void getUserRdnAttribute_returnsUid() {
+    assertThat(mapper.getUserRdnAttribute()).isEqualTo("uid");
+  }
+
+  @Test
+  void getGroupRdnAttribute_returnsCn() {
+    assertThat(mapper.getGroupRdnAttribute()).isEqualTo("cn");
+  }
+
+  @Test
+  void getUserObjectClasses_returnsConfiguredList() {
+    assertThat(mapper.getUserObjectClasses())
+      .containsExactly("inetOrgPerson", "organizationalPerson", "person", "top");
+  }
+
+  @Test
+  void getGroupObjectClasses_returnsConfiguredList() {
+    assertThat(mapper.getGroupObjectClasses())
+      .containsExactly("groupOfNames", "top");
+  }
+
+  // ── Additional edge cases ───────────────────────────────────────────
+
+  @Test
+  void toScimUser_partialName_onlyGivenName() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe",
+      "givenName: John"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getName()).isNotNull();
+    assertThat(user.getName().getGivenName()).isEqualTo("John");
+    assertThat(user.getName().getFamilyName()).isNull();
+    assertThat(user.getName().getFormatted()).isNull();
+  }
+
+  @Test
+  void toScimUser_partialAddress_onlyStreet() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "uid=jdoe,ou=users,dc=example,dc=com",
+      "uid: jdoe",
+      "street: 456 Oak Ave"
+    );
+
+    ScimUser user = mapper.toScimUser(entry);
+
+    assertThat(user.getAddresses()).hasSize(1);
+    assertThat(user.getAddresses().get(0).getStreetAddress()).isEqualTo("456 Oak Ave");
+    assertThat(user.getAddresses().get(0).getLocality()).isNull();
+    assertThat(user.getAddresses().get(0).getPostalCode()).isNull();
+  }
+
+  @Test
+  void toEntryUser_setsPhoneTypeMetadata() throws Exception {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+
+    PhoneNumber workPhone = new PhoneNumber();
+    workPhone.setValue("555-0001");
+    workPhone.setType("work");
+
+    PhoneNumber mobilePhone = new PhoneNumber();
+    mobilePhone.setValue("555-0002");
+    mobilePhone.setType("mobile");
+
+    user.setPhoneNumbers(List.of(workPhone, mobilePhone));
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    assertThat(entry.get("scimPhoneTypes").getString()).isEqualTo("work,mobile");
+  }
+
+  @Test
+  void toEntryUser_setsPassword() throws LdapException {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+    user.setPassword("s3cret");
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    assertThat(entry.get("userPassword").getString()).isEqualTo("s3cret");
+  }
+
+  @Test
+  void toEntryGroup_setsObjectClasses() throws LdapException {
+    ScimGroup group = new ScimGroup();
+    group.setDisplayName("Developers");
+
+    Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+    assertThat(entry.get("objectClass")).isNotNull();
+    assertThat(entry.get("objectClass").size()).isEqualTo(2);
+  }
+
+  @Test
+  void toEntryGroup_setsDisplayNameAttribute() throws LdapException {
+    ScimGroup group = new ScimGroup();
+    group.setDisplayName("Developers");
+
+    Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+    assertThat(entry.get("cn").getString()).isEqualTo("Developers");
+  }
+
+  @Test
+  void toScimGroup_noEntryUuid_idIsNull() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "cn=team,ou=groups,dc=example,dc=com",
+      "cn: Team"
+    );
+
+    ScimGroup group = mapper.toScimGroup(entry);
+
+    assertThat(group.getId()).isNull();
+    assertThat(group.getDisplayName()).isEqualTo("Team");
+  }
+
+  @Test
+  void toScimGroup_withBothTimestamps_populatesMetaVersionAndDates() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "cn=admins,ou=groups,dc=example,dc=com",
+      "cn: Admins",
+      "createTimestamp: 20240101120000Z",
+      "modifyTimestamp: 20240115143000Z"
+    );
+
+    ScimGroup group = mapper.toScimGroup(entry);
+
+    Meta meta = group.getMeta();
+    assertThat(meta).isNotNull();
+    assertThat(meta.getResourceType()).isEqualTo("Group");
+    assertThat(meta.getVersion()).isEqualTo("W/\"20240115143000Z\"");
+    assertThat(meta.getCreated()).isNotNull();
+    assertThat(meta.getLastModified()).isNotNull();
+  }
+
+  @Test
+  void toScimGroup_onlyCreateTimestamp_versionUsesCreateTimestamp() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "cn=admins,ou=groups,dc=example,dc=com",
+      "cn: Admins",
+      "createTimestamp: 20240101120000Z"
+    );
+
+    ScimGroup group = mapper.toScimGroup(entry);
+
+    Meta meta = group.getMeta();
+    assertThat(meta).isNotNull();
+    assertThat(meta.getVersion()).isEqualTo("W/\"20240101120000Z\"");
+    assertThat(meta.getCreated()).isNotNull();
+    assertThat(meta.getLastModified()).isNull();
+  }
+
+  @Test
+  void toScimGroup_noTimestamps_metaHasResourceTypeOnly() throws LdapInvalidAttributeValueException, LdapException {
+    Entry entry = new DefaultEntry(
+      "cn=admins,ou=groups,dc=example,dc=com",
+      "cn: Admins"
+    );
+
+    ScimGroup group = mapper.toScimGroup(entry);
+
+    Meta meta = group.getMeta();
+    assertThat(meta).isNotNull();
+    assertThat(meta.getResourceType()).isEqualTo("Group");
+    assertThat(meta.getVersion()).isNull();
+    assertThat(meta.getCreated()).isNull();
+    assertThat(meta.getLastModified()).isNull();
+  }
+
+  @Test
+  void toEntryUser_activeFlagStoredAsCustomAttribute() throws LdapException {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+    user.setActive(true);
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    assertThat(entry.get("scimActive").getString()).isEqualTo("true");
+  }
+
+  @Test
+  void toEntryUser_familyNameSet_snUsesFamilyName() throws LdapException {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+    Name name = new Name();
+    name.setFamilyName("Doe");
+    user.setName(name);
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    assertThat(entry.get("sn").getString()).isEqualTo("Doe");
+  }
+
+  @Test
+  void toEntryUser_formattedNameSet_cnUsesFormatted() throws LdapException {
+    ScimUser user = new ScimUser();
+    user.setUserName("jdoe");
+    Name name = new Name();
+    name.setFormatted("John Doe");
+    user.setName(name);
+
+    Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+    // cn is set from the name.formatted mapping (which maps to "cn")
+    assertThat(entry.get("cn").getString()).isEqualTo("John Doe");
+  }
+
+  // ── Helpers ─────────────────────────────────────────────────────────
+
+  private static Map<String, String> defaultUserAttributes() {
+    Map<String, String> attrs = new LinkedHashMap<>();
+    attrs.put("userName", "uid");
+    attrs.put("name.givenName", "givenName");
+    attrs.put("name.familyName", "sn");
+    attrs.put("name.formatted", "cn");
+    attrs.put("displayName", "displayName");
+    attrs.put("emails.value", "mail");
+    attrs.put("phoneNumbers.value", "telephoneNumber");
+    attrs.put("addresses.streetAddress", "street");
+    attrs.put("addresses.locality", "l");
+    attrs.put("addresses.postalCode", "postalCode");
+    attrs.put("title", "title");
+    attrs.put("userType", "employeeType");
+    attrs.put("password", "userPassword");
+    return attrs;
+  }
+
+  private static Map<String, String> defaultGroupAttributes() {
+    Map<String, String> attrs = new LinkedHashMap<>();
+    attrs.put("displayName", "cn");
+    attrs.put("members.value", "member");
+    return attrs;
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/FilterTranslatorTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/FilterTranslatorTest.java
new file mode 100644
index 0000000..feb8f00
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/FilterTranslatorTest.java
@@ -0,0 +1,444 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.mapping;
+
+import org.apache.directory.api.ldap.model.filter.AndNode;
+import org.apache.directory.api.ldap.model.filter.EqualityNode;
+import org.apache.directory.api.ldap.model.filter.ExprNode;
+import org.apache.directory.api.ldap.model.filter.GreaterEqNode;
+import org.apache.directory.api.ldap.model.filter.LessEqNode;
+import org.apache.directory.api.ldap.model.filter.NotNode;
+import org.apache.directory.api.ldap.model.filter.ObjectClassNode;
+import org.apache.directory.api.ldap.model.filter.OrNode;
+import org.apache.directory.api.ldap.model.filter.PresenceNode;
+import org.apache.directory.api.ldap.model.filter.SubstringNode;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.spec.filter.AttributeComparisonExpression;
+import org.apache.directory.scim.spec.filter.AttributePresentExpression;
+import org.apache.directory.scim.spec.filter.CompareOperator;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.GroupExpression;
+import org.apache.directory.scim.spec.filter.ValuePathExpression;
+import org.apache.directory.scim.spec.filter.attribute.AttributeReference;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class FilterTranslatorTest {
+
+  AttributeMapper attributeMapper = mock(AttributeMapper.class);
+  ScimLdapConfig config = mock(ScimLdapConfig.class);
+
+  FilterTranslator filterTranslator;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    filterTranslator = FilterTranslator.class.getDeclaredConstructor().newInstance();
+
+    Field mapperField = FilterTranslator.class.getDeclaredField("attributeMapper");
+    mapperField.setAccessible(true);
+    mapperField.set(filterTranslator, attributeMapper);
+
+    Field configField = FilterTranslator.class.getDeclaredField("config");
+    configField.setAccessible(true);
+    configField.set(filterTranslator, config);
+
+    when(config.getUserObjectClasses()).thenReturn(List.of("inetOrgPerson"));
+    when(config.getGroupObjectClasses()).thenReturn(List.of("groupOfNames"));
+  }
+
+  // --- Null/empty filter ---
+
+  @Test
+  void buildUserSearchFilter_nullFilter_returnsAndWithObjectClassNode() {
+    ExprNode result = filterTranslator.buildUserSearchFilter(null);
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    AndNode andNode = (AndNode) result;
+    assertThat(andNode.getChildren()).hasSize(2);
+    assertThat(andNode.getChildren().get(0)).isInstanceOf(EqualityNode.class);
+    assertThat(andNode.getChildren().get(0).toString()).contains("objectClass=inetOrgPerson");
+    assertThat(andNode.getChildren().get(1)).isSameAs(ObjectClassNode.OBJECT_CLASS_NODE);
+  }
+
+  @Test
+  void buildUserSearchFilter_emptyExpression_returnsAndWithObjectClassNode() throws Exception {
+    java.lang.reflect.Constructor<Filter> ctor = Filter.class.getDeclaredConstructor();
+    ctor.setAccessible(true);
+    Filter filter = ctor.newInstance();
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(filter);
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    AndNode andNode = (AndNode) result;
+    assertThat(andNode.getChildren().get(1)).isSameAs(ObjectClassNode.OBJECT_CLASS_NODE);
+  }
+
+  // --- Comparison operators ---
+
+  @Test
+  void buildUserSearchFilter_eqOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"john\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("uid=john");
+  }
+
+  @Test
+  void buildUserSearchFilter_neOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName ne \"john\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(NotNode.class);
+    ExprNode inner = ((NotNode) scimNode).getFirstChild();
+    assertThat(inner).isInstanceOf(EqualityNode.class);
+    assertThat(inner.toString()).contains("uid=john");
+  }
+
+  @Test
+  void buildUserSearchFilter_coOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName co \"oh\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(SubstringNode.class);
+    assertThat(scimNode.toString()).contains("uid=*oh*");
+  }
+
+  @Test
+  void buildUserSearchFilter_swOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName sw \"jo\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(SubstringNode.class);
+    assertThat(scimNode.toString()).contains("uid=jo*");
+  }
+
+  @Test
+  void buildUserSearchFilter_ewOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName ew \"hn\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(SubstringNode.class);
+    assertThat(scimNode.toString()).contains("uid=*hn");
+  }
+
+  @Test
+  void buildUserSearchFilter_gtOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName gt \"john\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(AndNode.class);
+    AndNode gtNode = (AndNode) scimNode;
+    assertThat(gtNode.getChildren().get(0)).isInstanceOf(GreaterEqNode.class);
+    assertThat(gtNode.getChildren().get(1)).isInstanceOf(NotNode.class);
+    assertThat(gtNode.getChildren().get(0).toString()).contains("uid>=john");
+  }
+
+  @Test
+  void buildUserSearchFilter_geOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName ge \"john\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(GreaterEqNode.class);
+    assertThat(scimNode.toString()).contains("uid>=john");
+  }
+
+  @Test
+  void buildUserSearchFilter_ltOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName lt \"john\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(AndNode.class);
+    AndNode ltNode = (AndNode) scimNode;
+    assertThat(ltNode.getChildren().get(0)).isInstanceOf(LessEqNode.class);
+    assertThat(ltNode.getChildren().get(1)).isInstanceOf(NotNode.class);
+    assertThat(ltNode.getChildren().get(0).toString()).contains("uid<=john");
+  }
+
+  @Test
+  void buildUserSearchFilter_leOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName le \"john\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(LessEqNode.class);
+    assertThat(scimNode.toString()).contains("uid<=john");
+  }
+
+  @Test
+  void buildUserSearchFilter_prOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName pr"));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(PresenceNode.class);
+    assertThat(scimNode.toString()).contains("uid=*");
+  }
+
+  // --- AttributePresentExpression ---
+
+  @Test
+  void buildUserSearchFilter_attributePresentExpression() {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+    Filter filter = new Filter(new AttributePresentExpression(new AttributeReference("userName")));
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(filter);
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(PresenceNode.class);
+    assertThat(scimNode.toString()).contains("uid=*");
+  }
+
+  // --- Logical operators ---
+
+  @Test
+  void buildUserSearchFilter_andOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+    when(attributeMapper.getLdapUserAttribute("displayName")).thenReturn("cn");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(
+      new Filter("userName eq \"john\" and displayName eq \"John Doe\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(AndNode.class);
+    AndNode innerAnd = (AndNode) scimNode;
+    assertThat(innerAnd.getChildren()).hasSize(2);
+    assertThat(innerAnd.getChildren().get(0)).isInstanceOf(EqualityNode.class);
+    assertThat(innerAnd.getChildren().get(1)).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("uid=john");
+    assertThat(scimNode.toString()).contains("cn=John Doe");
+  }
+
+  @Test
+  void buildUserSearchFilter_orOperator() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+    when(attributeMapper.getLdapUserAttribute("displayName")).thenReturn("cn");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(
+      new Filter("userName eq \"john\" or displayName eq \"John Doe\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(OrNode.class);
+    OrNode orNode = (OrNode) scimNode;
+    assertThat(orNode.getChildren()).hasSize(2);
+    assertThat(scimNode.toString()).contains("uid=john");
+    assertThat(scimNode.toString()).contains("cn=John Doe");
+  }
+
+  // --- GroupExpression ---
+
+  @Test
+  void buildUserSearchFilter_groupExpressionNotTrue() {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+    AttributeComparisonExpression inner = new AttributeComparisonExpression(
+      new AttributeReference("userName"), CompareOperator.EQ, "john");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter(new GroupExpression(true, inner)));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(NotNode.class);
+    assertThat(scimNode.toString()).contains("uid=john");
+  }
+
+  @Test
+  void buildUserSearchFilter_groupExpressionNotFalse() {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+    AttributeComparisonExpression inner = new AttributeComparisonExpression(
+      new AttributeReference("userName"), CompareOperator.EQ, "john");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter(new GroupExpression(false, inner)));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("uid=john");
+  }
+
+  // --- ValuePathExpression ---
+
+  @Test
+  void buildUserSearchFilter_valuePathExpressionWithInnerExpression() {
+    when(attributeMapper.getLdapUserAttribute("type")).thenReturn("emailType");
+    AttributeComparisonExpression inner = new AttributeComparisonExpression(
+      new AttributeReference("type"), CompareOperator.EQ, "work");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(
+      new Filter(ValuePathExpression.fromFilterExpression("emails", inner)));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("emailType=work");
+  }
+
+  @Test
+  void buildUserSearchFilter_valuePathExpressionWithoutInnerExpression_throwsUnsupported() {
+    Filter filter = new Filter(new ValuePathExpression(new AttributeReference("emails")));
+    assertThatThrownBy(() -> filterTranslator.buildUserSearchFilter(filter))
+      .isInstanceOf(UnsupportedOperationException.class)
+      .hasMessageContaining("ValuePathExpression without an attribute expression");
+  }
+
+  // --- Attribute resolution ---
+
+  @Test
+  void buildUserSearchFilter_unmappedAttribute_fallsBackToScimName() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn(null);
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"john\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("userName=john");
+  }
+
+  @Test
+  void buildUserSearchFilter_subAttribute_resolvesCorrectly() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("name.givenName")).thenReturn("givenName");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("name.givenName eq \"John\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("givenName=John");
+  }
+
+  // --- Special characters in values ---
+
+  @Test
+  void buildUserSearchFilter_specialCharactersAreEscaped() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"jo*hn\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("uid=jo\\2Ahn");
+  }
+
+  @Test
+  void buildUserSearchFilter_parenthesesInValueAreEscaped() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"jo(h)n\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("uid=jo\\28h\\29n");
+  }
+
+  @Test
+  void buildUserSearchFilter_backslashInValueIsEscaped() throws Exception {
+    when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"jo\\\\hn\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("uid=jo\\5C\\5Chn");
+  }
+
+  // --- Group filter uses group attribute mapping ---
+
+  @Test
+  void buildGroupSearchFilter_usesGroupAttributeMapping() throws Exception {
+    when(attributeMapper.getLdapGroupAttribute("displayName")).thenReturn("cn");
+
+    ExprNode result = filterTranslator.buildGroupSearchFilter(new Filter("displayName eq \"Admins\""));
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    AndNode andNode = (AndNode) result;
+    assertThat(andNode.getChildren().get(0)).isInstanceOf(EqualityNode.class);
+    assertThat(andNode.getChildren().get(0).toString()).contains("objectClass=groupOfNames");
+    ExprNode scimNode = andNode.getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(EqualityNode.class);
+    assertThat(scimNode.toString()).contains("cn=Admins");
+  }
+
+  @Test
+  void buildGroupSearchFilter_nullFilter_returnsAndWithObjectClassNode() {
+    ExprNode result = filterTranslator.buildGroupSearchFilter(null);
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    AndNode andNode = (AndNode) result;
+    assertThat(andNode.getChildren()).hasSize(2);
+    assertThat(andNode.getChildren().get(0)).isInstanceOf(EqualityNode.class);
+    assertThat(andNode.getChildren().get(0).toString()).contains("objectClass=groupOfNames");
+    assertThat(andNode.getChildren().get(1)).isSameAs(ObjectClassNode.OBJECT_CLASS_NODE);
+  }
+
+  @Test
+  void buildUserSearchFilter_unmappedPresentAttribute_fallsBackToScimName() {
+    when(attributeMapper.getLdapUserAttribute("unknownAttr")).thenReturn(null);
+    Filter filter = new Filter(new AttributePresentExpression(new AttributeReference("unknownAttr")));
+
+    ExprNode result = filterTranslator.buildUserSearchFilter(filter);
+
+    assertThat(result).isInstanceOf(AndNode.class);
+    ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+    assertThat(scimNode).isInstanceOf(PresenceNode.class);
+    assertThat(scimNode.toString()).contains("unknownAttr=*");
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapGroupRepositoryTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapGroupRepositoryTest.java
new file mode 100644
index 0000000..a303f46
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapGroupRepositoryTest.java
@@ -0,0 +1,724 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.service;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.apache.directory.scim.spec.resources.GroupMembership;
+import org.apache.directory.scim.spec.resources.ScimGroup;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LdapGroupRepositoryTest {
+
+  static final String GROUP_BASE_DN = "ou=groups,dc=example,dc=com";
+  static final String USER_BASE_DN = "ou=users,dc=example,dc=com";
+
+  LdapDao ldapDao = mock(LdapDao.class);
+  AttributeMapper attributeMapper = mock(AttributeMapper.class);
+  ScimLdapConfig config = mock(ScimLdapConfig.class);
+
+  LdapGroupRepository repository;
+  ScimRequestContext requestContext;
+
+  @BeforeEach
+  void setUp() {
+    when(config.getGroupBaseDn()).thenReturn(GROUP_BASE_DN);
+    when(config.getUserBaseDn()).thenReturn(USER_BASE_DN);
+    when(attributeMapper.getGroupRdnAttribute()).thenReturn("cn");
+
+    repository = new LdapGroupRepository(ldapDao, attributeMapper, config);
+    requestContext = ScimRequestContext.empty();
+  }
+
+  // =========================================================================
+  // Helper methods
+  // =========================================================================
+
+  private static Entry memberEntryWithMailAndUuid(String dn, String uuid, String mail) throws Exception {
+    Entry entry = new DefaultEntry(dn);
+    entry.add("entryUUID", uuid);
+    entry.add("mail", mail);
+    return entry;
+  }
+
+  private static Entry memberEntryWithCnAndUuid(String dn, String uuid, String cn) throws Exception {
+    Entry entry = new DefaultEntry(dn);
+    entry.add("entryUUID", uuid);
+    entry.add("cn", cn);
+    return entry;
+  }
+
+  private static ScimGroup scimGroupWithMembers(String displayName, List<GroupMembership> members) {
+    ScimGroup group = new ScimGroup();
+    group.setDisplayName(displayName);
+    group.setMembers(members);
+    return group;
+  }
+
+  private static GroupMembership membership(String value) {
+    GroupMembership m = new GroupMembership();
+    m.setValue(value);
+    return m;
+  }
+
+  private static ScimGroup firstResource(FilterResponse<ScimGroup> response) {
+    return response.getResources().iterator().next();
+  }
+
+  // =========================================================================
+  // find
+  // =========================================================================
+
+  @Nested
+  @DisplayName("find")
+  class FindTest {
+
+    @Test
+    @DisplayName("null filter delegates to findGroups")
+    void nullFilter_delegatesToFindGroups() throws Exception {
+      when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(Collections.emptyList(), 0));
+
+      FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+
+      assertThat(response.getResources()).isEmpty();
+      verify(ldapDao).findGroups(any(), any());
+    }
+
+    @Test
+    @DisplayName("with filter delegates to findGroups")
+    void withFilter_delegatesToFindGroups() throws Exception {
+      Filter filter = mock(Filter.class);
+      when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(Collections.emptyList(), 0));
+
+      repository.find(filter, requestContext);
+
+      verify(ldapDao).findGroups(any(), any());
+    }
+
+    @Test
+    @DisplayName("returns groups with resolved members")
+    void returnsGroupsWithResolvedMembers() throws Exception {
+      Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      groupEntry.add("entryUUID", "group-uuid-1");
+
+      ScimGroup mappedGroup = scimGroupWithMembers("admins",
+        new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+      mappedGroup.setId("group-uuid-1");
+      when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+      Entry memberEntry = memberEntryWithMailAndUuid("uid=jdoe,ou=users,dc=example,dc=com", "member-uuid-1", "jdoe@example.com");
+      when(ldapDao.lookup("uid=jdoe,ou=users,dc=example,dc=com")).thenReturn(memberEntry);
+
+      when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(List.of(groupEntry), 1));
+
+      FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+
+      assertThat(response.getResources()).hasSize(1);
+      ScimGroup result = firstResource(response);
+      assertThat(result.getMembers()).hasSize(1);
+      assertThat(result.getMembers().get(0).getValue()).isEqualTo("member-uuid-1");
+      assertThat(result.getMembers().get(0).getDisplay()).isEqualTo("jdoe@example.com");
+    }
+
+    @Test
+    @DisplayName("member resolution prefers mail for display")
+    void memberResolutionPrefersMailForDisplay() throws Exception {
+      Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      ScimGroup mappedGroup = scimGroupWithMembers("admins",
+        new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+      when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+      Entry memberEntry = new DefaultEntry("uid=jdoe,ou=users,dc=example,dc=com");
+      memberEntry.add("entryUUID", "member-uuid-1");
+      memberEntry.add("mail", "jdoe@example.com");
+      memberEntry.add("cn", "John Doe");
+      when(ldapDao.lookup("uid=jdoe,ou=users,dc=example,dc=com")).thenReturn(memberEntry);
+
+      when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(List.of(groupEntry), 1));
+
+      FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+      ScimGroup result = firstResource(response);
+      assertThat(result.getMembers().get(0).getDisplay()).isEqualTo("jdoe@example.com");
+    }
+
+    @Test
+    @DisplayName("member resolution falls back to cn when no mail")
+    void memberResolutionFallsBackToCn() throws Exception {
+      Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      ScimGroup mappedGroup = scimGroupWithMembers("admins",
+        new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+      when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+      Entry memberEntry = memberEntryWithCnAndUuid("uid=jdoe,ou=users,dc=example,dc=com", "member-uuid-1", "John Doe");
+      when(ldapDao.lookup("uid=jdoe,ou=users,dc=example,dc=com")).thenReturn(memberEntry);
+
+      when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(List.of(groupEntry), 1));
+
+      FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+      ScimGroup result = firstResource(response);
+      assertThat(result.getMembers().get(0).getDisplay()).isEqualTo("John Doe");
+    }
+
+    @Test
+    @DisplayName("stale member reference is skipped")
+    void staleMemberReference_memberSkipped() throws Exception {
+      Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      ScimGroup mappedGroup = scimGroupWithMembers("admins",
+        new ArrayList<>(List.of(membership("uid=deleted,ou=users,dc=example,dc=com"))));
+      when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+      when(ldapDao.lookup("uid=deleted,ou=users,dc=example,dc=com"))
+        .thenThrow(new ResourceNotFoundException("uid=deleted,ou=users,dc=example,dc=com"));
+
+      when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(List.of(groupEntry), 1));
+
+      FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+      ScimGroup result = firstResource(response);
+      assertThat(result.getMembers()).isEmpty();
+    }
+
+    @Test
+    @DisplayName("empty results returns empty FilterResponse")
+    void emptyResults_returnsEmptyFilterResponse() throws Exception {
+      when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(Collections.emptyList(), 0));
+
+      FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+
+      assertThat(response.getResources()).isEmpty();
+      assertThat(response.getTotalResults()).isZero();
+    }
+
+    // ----- Pagination tests -----
+
+    @Test
+    @DisplayName("totalResults reflects total matching entries, not page size")
+    void totalResults_reflectsTotalNotPageSize() throws Exception {
+      Entry groupEntry1 = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      Entry groupEntry2 = new DefaultEntry("cn=devs," + GROUP_BASE_DN);
+
+      ScimGroup group1 = new ScimGroup();
+      group1.setDisplayName("admins");
+      ScimGroup group2 = new ScimGroup();
+      group2.setDisplayName("devs");
+
+      when(ldapDao.findGroups(any(), any()))
+        .thenReturn(new FilterResponse<>(List.of(groupEntry1, groupEntry2), 3));
+      when(attributeMapper.toScimGroup(groupEntry1)).thenReturn(group1);
+      when(attributeMapper.toScimGroup(groupEntry2)).thenReturn(group2);
+
+      ScimRequestContext context = new ScimRequestContext()
+        .setPageRequest(new PageRequest().setStartIndex(1).setCount(2));
+
+      FilterResponse<ScimGroup> response = repository.find(null, context);
+
+      assertThat(response.getResources()).containsExactly(group1, group2);
+      assertThat(response.getTotalResults()).isEqualTo(3);
+    }
+
+    @Test
+    @DisplayName("pagination middle page returns correct slice with total count")
+    void paginationMiddlePage_returnsCorrectSlice() throws Exception {
+      Entry groupEntry2 = new DefaultEntry("cn=devs," + GROUP_BASE_DN);
+
+      ScimGroup group2 = new ScimGroup();
+      group2.setDisplayName("devs");
+
+      when(ldapDao.findGroups(any(), any()))
+        .thenReturn(new FilterResponse<>(List.of(groupEntry2), 3));
+      when(attributeMapper.toScimGroup(groupEntry2)).thenReturn(group2);
+
+      ScimRequestContext context = new ScimRequestContext()
+        .setPageRequest(new PageRequest().setStartIndex(2).setCount(1));
+
+      FilterResponse<ScimGroup> response = repository.find(null, context);
+
+      assertThat(response.getResources()).containsExactly(group2);
+      assertThat(response.getTotalResults()).isEqualTo(3);
+    }
+
+    @Test
+    @DisplayName("no pagination returns all with correct totalResults")
+    void noPagination_returnsAllWithCorrectTotalResults() throws Exception {
+      Entry groupEntry1 = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      Entry groupEntry2 = new DefaultEntry("cn=devs," + GROUP_BASE_DN);
+
+      ScimGroup group1 = new ScimGroup();
+      group1.setDisplayName("admins");
+      ScimGroup group2 = new ScimGroup();
+      group2.setDisplayName("devs");
+
+      when(ldapDao.findGroups(any(), any()))
+        .thenReturn(new FilterResponse<>(List.of(groupEntry1, groupEntry2), 2));
+      when(attributeMapper.toScimGroup(groupEntry1)).thenReturn(group1);
+      when(attributeMapper.toScimGroup(groupEntry2)).thenReturn(group2);
+
+      FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+
+      assertThat(response.getResources()).containsExactly(group1, group2);
+      assertThat(response.getTotalResults()).isEqualTo(2);
+    }
+  }
+
+  // =========================================================================
+  // get
+  // =========================================================================
+
+  @Nested
+  @DisplayName("get")
+  class GetTest {
+
+    @Test
+    @DisplayName("found returns ScimGroup with resolved members")
+    void found_returnsScimGroupWithResolvedMembers() throws Exception {
+      Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      groupEntry.add("entryUUID", "group-uuid-1");
+
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "group-uuid-1"))
+        .thenReturn(groupEntry);
+
+      ScimGroup mappedGroup = scimGroupWithMembers("admins",
+        new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+      mappedGroup.setId("group-uuid-1");
+      when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+      Entry memberEntry = memberEntryWithMailAndUuid("uid=jdoe,ou=users,dc=example,dc=com", "member-uuid-1", "jdoe@example.com");
+      when(ldapDao.lookup("uid=jdoe,ou=users,dc=example,dc=com")).thenReturn(memberEntry);
+
+      ScimGroup result = repository.get("group-uuid-1", requestContext);
+
+      assertThat(result).isNotNull();
+      assertThat(result.getDisplayName()).isEqualTo("admins");
+      assertThat(result.getMembers()).hasSize(1);
+      assertThat(result.getMembers().get(0).getValue()).isEqualTo("member-uuid-1");
+      assertThat(result.getMembers().get(0).getDisplay()).isEqualTo("jdoe@example.com");
+    }
+
+    @Test
+    @DisplayName("not found returns null")
+    void notFound_returnsNull() throws Exception {
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "missing-uuid"))
+        .thenReturn(null);
+
+      ScimGroup result = repository.get("missing-uuid", requestContext);
+
+      assertThat(result).isNull();
+    }
+  }
+
+  // =========================================================================
+  // create
+  // =========================================================================
+
+  @Nested
+  @DisplayName("create")
+  class CreateTest {
+
+    @Test
+    @DisplayName("success with member UUID resolution")
+    void successWithMemberUuidResolution() throws Exception {
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      GroupMembership member = membership("member-uuid-1");
+      resource.setMembers(new ArrayList<>(List.of(member)));
+
+      // Resolve member UUID to DN
+      Entry memberEntry = new DefaultEntry("uid=jdoe,ou=users,dc=example,dc=com");
+      memberEntry.add("entryUUID", "member-uuid-1");
+      when(ldapDao.searchByAttribute(USER_BASE_DN, "entryUUID", "member-uuid-1"))
+        .thenReturn(memberEntry);
+
+      Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+      Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      createdEntry.add("entryUUID", "new-group-uuid");
+      when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+      ScimGroup mappedResult = new ScimGroup();
+      mappedResult.setId("new-group-uuid");
+      mappedResult.setDisplayName("admins");
+      when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+      ScimGroup result = repository.create(resource, requestContext);
+
+      assertThat(result).isNotNull();
+      assertThat(result.getId()).isEqualTo("new-group-uuid");
+      verify(ldapDao).create(ldapEntry);
+      verify(ldapDao).lookup("cn=admins," + GROUP_BASE_DN);
+    }
+
+    @Test
+    @DisplayName("member not found throws ResourceException 400")
+    void memberNotFound_throwsResourceException400() throws Exception {
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      resource.setMembers(new ArrayList<>(List.of(membership("nonexistent-uuid"))));
+
+      when(ldapDao.searchByAttribute(USER_BASE_DN, "entryUUID", "nonexistent-uuid"))
+        .thenReturn(null);
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "nonexistent-uuid"))
+        .thenReturn(null);
+
+      assertThatThrownBy(() -> repository.create(resource, requestContext))
+        .isInstanceOf(ResourceException.class)
+        .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(400));
+    }
+
+    @Test
+    @DisplayName("member with DN value (contains =) is validated and passed through")
+    void memberWithDnValue_validatedAndPassedThrough() throws Exception {
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      String memberDn = "uid=jdoe,ou=users,dc=example,dc=com";
+      resource.setMembers(new ArrayList<>(List.of(membership(memberDn))));
+
+      Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+      Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      createdEntry.add("entryUUID", "new-group-uuid");
+      when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+      ScimGroup mappedResult = new ScimGroup();
+      mappedResult.setId("new-group-uuid");
+      mappedResult.setDisplayName("admins");
+      when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+      ScimGroup result = repository.create(resource, requestContext);
+
+      assertThat(result).isNotNull();
+      // Verify the member DN was kept (not looked up as UUID)
+      verify(ldapDao, never()).searchByAttribute(anyString(), eq("entryUUID"), eq(memberDn));
+    }
+
+    @Test
+    @DisplayName("invalid DN throws ResourceException 400")
+    void invalidDn_throwsResourceException400() throws Exception {
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      resource.setMembers(new ArrayList<>(List.of(membership("not=a=valid=dn=at=all"))));
+
+      assertThatThrownBy(() -> repository.create(resource, requestContext))
+        .isInstanceOf(ResourceException.class)
+        .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(400));
+    }
+
+    @Test
+    @DisplayName("DN not under known base throws ResourceException 400")
+    void dnNotUnderKnownBase_throwsResourceException400() throws Exception {
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      resource.setMembers(new ArrayList<>(List.of(membership("uid=jdoe,ou=unknown,dc=other,dc=com"))));
+
+      assertThatThrownBy(() -> repository.create(resource, requestContext))
+        .isInstanceOf(ResourceException.class)
+        .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(400));
+    }
+  }
+
+  // =========================================================================
+  // update
+  // =========================================================================
+
+  @Nested
+  @DisplayName("update")
+  class UpdateTest {
+
+    @Test
+    @DisplayName("found resolves members, modifies, and returns")
+    void found_resolvesMembers_modifies_returns() throws Exception {
+      String groupId = "group-uuid-1";
+      Entry existingEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      existingEntry.add("entryUUID", groupId);
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", groupId)).thenReturn(existingEntry);
+
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      resource.setMembers(new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+
+      Entry updatedEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      updatedEntry.add("member", "uid=jdoe,ou=users,dc=example,dc=com");
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(updatedEntry);
+
+      Entry resultEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      resultEntry.add("entryUUID", groupId);
+      when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(resultEntry);
+
+      ScimGroup mappedResult = new ScimGroup();
+      mappedResult.setId(groupId);
+      mappedResult.setDisplayName("admins");
+      when(attributeMapper.toScimGroup(resultEntry)).thenReturn(mappedResult);
+
+      ScimGroup result = repository.update(groupId, resource, requestContext);
+
+      assertThat(result).isNotNull();
+      assertThat(result.getId()).isEqualTo(groupId);
+    }
+
+    @Test
+    @DisplayName("not found throws ResourceNotFoundException")
+    void notFound_throwsResourceNotFoundException() throws Exception {
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "missing-uuid"))
+        .thenReturn(null);
+
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+
+      assertThatThrownBy(() -> repository.update("missing-uuid", resource, requestContext))
+        .isInstanceOf(ResourceNotFoundException.class);
+    }
+
+    @Test
+    @DisplayName("displayName changed calls rename before modify")
+    void displayNameChanged_callsRenameBeforeModify() throws Exception {
+      String groupId = "group-uuid-1";
+      Entry existingEntry = new DefaultEntry("cn=old," + GROUP_BASE_DN, "cn: old");
+      existingEntry.add("entryUUID", groupId);
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", groupId)).thenReturn(existingEntry);
+
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("new");
+
+      Entry updatedEntry = new DefaultEntry("cn=new," + GROUP_BASE_DN);
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(updatedEntry);
+
+      Entry resultEntry = new DefaultEntry("cn=new," + GROUP_BASE_DN);
+      resultEntry.add("entryUUID", groupId);
+      when(ldapDao.lookup(anyString())).thenReturn(resultEntry);
+
+      ScimGroup mappedResult = new ScimGroup();
+      mappedResult.setId(groupId);
+      mappedResult.setDisplayName("new");
+      when(attributeMapper.toScimGroup(resultEntry)).thenReturn(mappedResult);
+
+      repository.update(groupId, resource, requestContext);
+
+      verify(ldapDao).rename("cn=old," + GROUP_BASE_DN, "cn=new");
+    }
+
+    @Test
+    @DisplayName("displayName unchanged does not call rename")
+    void displayNameUnchanged_doesNotCallRename() throws Exception {
+      String groupId = "group-uuid-1";
+      Entry existingEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN, "cn: admins");
+      existingEntry.add("entryUUID", groupId);
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", groupId)).thenReturn(existingEntry);
+
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+
+      Entry updatedEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(updatedEntry);
+
+      Entry resultEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      resultEntry.add("entryUUID", groupId);
+      when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(resultEntry);
+
+      ScimGroup mappedResult = new ScimGroup();
+      mappedResult.setId(groupId);
+      mappedResult.setDisplayName("admins");
+      when(attributeMapper.toScimGroup(resultEntry)).thenReturn(mappedResult);
+
+      repository.update(groupId, resource, requestContext);
+
+      verify(ldapDao, never()).rename(anyString(), anyString());
+    }
+
+    @Test
+    @DisplayName("displayName changed, rename succeeds, modify fails — throws ResourceException")
+    void displayNameChanged_renameSucceeds_modifyFails_throwsResourceException() throws Exception {
+      String groupId = "group-uuid-1";
+      Entry existingEntry = new DefaultEntry("cn=old," + GROUP_BASE_DN, "cn: old");
+      existingEntry.add("entryUUID", groupId);
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", groupId)).thenReturn(existingEntry);
+
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("new");
+
+      // Include a member attribute so buildReplaceModifications produces a modification
+      Entry updatedEntry = new DefaultEntry("cn=new," + GROUP_BASE_DN);
+      updatedEntry.add("member", "uid=jdoe,ou=users,dc=example,dc=com");
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(updatedEntry);
+
+      doThrow(new ResourceException(500, "LDAP modify failed"))
+        .when(ldapDao).modify(anyString(), any(Modification[].class));
+
+      assertThatThrownBy(() -> repository.update(groupId, resource, requestContext))
+        .isInstanceOf(ResourceException.class);
+    }
+  }
+
+  // =========================================================================
+  // delete
+  // =========================================================================
+
+  @Nested
+  @DisplayName("delete")
+  class DeleteTest {
+
+    @Test
+    @DisplayName("found deletes by DN")
+    void found_deletesByDn() throws Exception {
+      Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      groupEntry.add("entryUUID", "group-uuid-1");
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "group-uuid-1"))
+        .thenReturn(groupEntry);
+
+      repository.delete("group-uuid-1");
+
+      verify(ldapDao).delete("cn=admins," + GROUP_BASE_DN);
+    }
+
+    @Test
+    @DisplayName("not found throws ResourceNotFoundException")
+    void notFound_throwsResourceNotFoundException() throws Exception {
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "missing-uuid"))
+        .thenReturn(null);
+
+      assertThatThrownBy(() -> repository.delete("missing-uuid"))
+        .isInstanceOf(ResourceNotFoundException.class);
+    }
+  }
+
+  // =========================================================================
+  // Member resolution details
+  // =========================================================================
+
+  @Nested
+  @DisplayName("member resolution details")
+  class MemberResolutionTest {
+
+    @Test
+    @DisplayName("UUID member searched in users first then groups")
+    void uuidMember_searchedInUsersFirstThenGroups() throws Exception {
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      resource.setMembers(new ArrayList<>(List.of(membership("some-uuid"))));
+
+      // Not found in users
+      when(ldapDao.searchByAttribute(USER_BASE_DN, "entryUUID", "some-uuid"))
+        .thenReturn(null);
+      // Found in groups
+      Entry groupMemberEntry = new DefaultEntry("cn=subgroup," + GROUP_BASE_DN);
+      groupMemberEntry.add("entryUUID", "some-uuid");
+      when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "some-uuid"))
+        .thenReturn(groupMemberEntry);
+
+      Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+      Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      createdEntry.add("entryUUID", "new-group-uuid");
+      when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+      ScimGroup mappedResult = new ScimGroup();
+      mappedResult.setId("new-group-uuid");
+      when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+      repository.create(resource, requestContext);
+
+      // Verify users searched first
+      verify(ldapDao).searchByAttribute(USER_BASE_DN, "entryUUID", "some-uuid");
+      // Then groups searched
+      verify(ldapDao).searchByAttribute(GROUP_BASE_DN, "entryUUID", "some-uuid");
+    }
+
+    @Test
+    @DisplayName("null member value is skipped")
+    void nullMemberValue_skipped() throws Exception {
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      GroupMembership nullMember = new GroupMembership();
+      // value is null by default
+      resource.setMembers(new ArrayList<>(List.of(nullMember)));
+
+      Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+      Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      createdEntry.add("entryUUID", "new-group-uuid");
+      when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+      ScimGroup mappedResult = new ScimGroup();
+      mappedResult.setId("new-group-uuid");
+      when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+      ScimGroup result = repository.create(resource, requestContext);
+
+      assertThat(result).isNotNull();
+      // No searchByAttribute should be called for null member
+      verify(ldapDao, never()).searchByAttribute(anyString(), eq("entryUUID"), any());
+    }
+
+    @Test
+    @DisplayName("member already a DN (contains =) is validated via Dn and base DN check")
+    void memberAlreadyDn_validatedViaDnAndBaseDnCheck() throws Exception {
+      ScimGroup resource = new ScimGroup();
+      resource.setDisplayName("admins");
+      String memberDn = "cn=subgroup,ou=groups,dc=example,dc=com";
+      resource.setMembers(new ArrayList<>(List.of(membership(memberDn))));
+
+      Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+      Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+      createdEntry.add("entryUUID", "new-group-uuid");
+      when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+      ScimGroup mappedResult = new ScimGroup();
+      mappedResult.setId("new-group-uuid");
+      when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+      ScimGroup result = repository.create(resource, requestContext);
+
+      assertThat(result).isNotNull();
+      // The DN member should not trigger UUID resolution
+      verify(ldapDao, never()).searchByAttribute(anyString(), eq("entryUUID"), eq(memberDn));
+    }
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapUserRepositoryTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapUserRepositoryTest.java
new file mode 100644
index 0000000..0ef7a83
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapUserRepositoryTest.java
@@ -0,0 +1,497 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements.  See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership.  The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License.  You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied.  See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.service;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.apache.directory.scim.spec.resources.ScimUser;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LdapUserRepositoryTest {
+
+  static final String BASE_DN = "ou=users,dc=example,dc=com";
+
+  LdapDao ldapDao = mock(LdapDao.class);
+  AttributeMapper attributeMapper = mock(AttributeMapper.class);
+  ScimLdapConfig config = mock(ScimLdapConfig.class);
+
+  LdapUserRepository repository;
+
+  @BeforeEach
+  void setUp() {
+    when(config.getUserBaseDn()).thenReturn(BASE_DN);
+    when(attributeMapper.getUserRdnAttribute()).thenReturn("uid");
+
+    repository = new LdapUserRepository(ldapDao, attributeMapper, config);
+  }
+
+  // =========================================================================
+  // find
+  // =========================================================================
+
+  @Nested
+  @DisplayName("find")
+  class FindTest {
+
+    @Test
+    void nullFilterSearchesWithObjectClassFilter() throws Exception {
+      Entry entry1 = new DefaultEntry("uid=alice," + BASE_DN);
+      ScimUser user1 = new ScimUser();
+      user1.setId("uuid-1");
+
+      when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(List.of(entry1), 1));
+      when(attributeMapper.toScimUser(entry1)).thenReturn(user1);
+
+      FilterResponse<ScimUser> response = repository.find(null, ScimRequestContext.empty());
+
+      assertThat(response.getResources()).containsExactly(user1);
+      assertThat(response.getTotalResults()).isEqualTo(1);
+    }
+
+    @Test
+    void withFilterDelegatesToFindUsers() throws Exception {
+      Filter filter = new Filter("userName eq \"john\"");
+
+      Entry entry = new DefaultEntry("uid=john," + BASE_DN);
+      ScimUser user = new ScimUser();
+      user.setId("uuid-john");
+
+      when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(List.of(entry), 1));
+      when(attributeMapper.toScimUser(entry)).thenReturn(user);
+
+      FilterResponse<ScimUser> response = repository.find(filter, ScimRequestContext.empty());
+
+      assertThat(response.getResources()).containsExactly(user);
+      assertThat(response.getTotalResults()).isEqualTo(1);
+    }
+
+    @Test
+    void returnsAllMappedUsers() throws Exception {
+      Entry entry1 = new DefaultEntry("uid=alice," + BASE_DN);
+      Entry entry2 = new DefaultEntry("uid=bob," + BASE_DN);
+      ScimUser user1 = new ScimUser();
+      user1.setId("uuid-1");
+      ScimUser user2 = new ScimUser();
+      user2.setId("uuid-2");
+
+      when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(List.of(entry1, entry2), 2));
+      when(attributeMapper.toScimUser(entry1)).thenReturn(user1);
+      when(attributeMapper.toScimUser(entry2)).thenReturn(user2);
+
+      FilterResponse<ScimUser> response = repository.find(null, ScimRequestContext.empty());
+
+      assertThat(response.getResources()).containsExactly(user1, user2);
+      assertThat(response.getTotalResults()).isEqualTo(2);
+    }
+
+    @Test
+    void emptyResultsReturnsEmptyFilterResponse() throws Exception {
+      when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(Collections.emptyList(), 0));
+
+      FilterResponse<ScimUser> response = repository.find(null, ScimRequestContext.empty());
+
+      assertThat(response.getResources()).isEmpty();
+      assertThat(response.getTotalResults()).isEqualTo(0);
+    }
+
+    // ----- Pagination helpers and tests using 5 entries -----
+
+    private static ScimUser newUser(String id) {
+      ScimUser u = new ScimUser();
+      u.setId(id);
+      return u;
+    }
+
+    ScimUser pUser1 = newUser("uuid-p1");
+    ScimUser pUser2 = newUser("uuid-p2");
+    ScimUser pUser3 = newUser("uuid-p3");
+    ScimUser pUser4 = newUser("uuid-p4");
+    ScimUser pUser5 = newUser("uuid-p5");
+
+    Entry pEntry1;
+    Entry pEntry2;
+    Entry pEntry3;
+    Entry pEntry4;
+    Entry pEntry5;
+
+    private void setupEntryMappings() throws Exception {
+      pEntry1 = new DefaultEntry("uid=alice," + BASE_DN);
+      pEntry2 = new DefaultEntry("uid=bob," + BASE_DN);
+      pEntry3 = new DefaultEntry("uid=carol," + BASE_DN);
+      pEntry4 = new DefaultEntry("uid=dave," + BASE_DN);
+      pEntry5 = new DefaultEntry("uid=eve," + BASE_DN);
+
+      when(attributeMapper.toScimUser(pEntry1)).thenReturn(pUser1);
+      when(attributeMapper.toScimUser(pEntry2)).thenReturn(pUser2);
+      when(attributeMapper.toScimUser(pEntry3)).thenReturn(pUser3);
+      when(attributeMapper.toScimUser(pEntry4)).thenReturn(pUser4);
+      when(attributeMapper.toScimUser(pEntry5)).thenReturn(pUser5);
+    }
+
+    @Test
+    void paginationFirstPage() throws Exception {
+      setupEntryMappings();
+
+      when(ldapDao.findUsers(any(), any()))
+        .thenReturn(new FilterResponse<>(List.of(pEntry1, pEntry2), 5));
+
+      ScimRequestContext context = new ScimRequestContext()
+        .setPageRequest(new PageRequest().setStartIndex(1).setCount(2));
+
+      FilterResponse<ScimUser> response = repository.find(null, context);
+
+      assertThat(response.getResources()).containsExactly(pUser1, pUser2);
+      assertThat(response.getTotalResults()).isEqualTo(5);
+    }
+
+    @Test
+    void paginationMiddlePage() throws Exception {
+      setupEntryMappings();
+
+      when(ldapDao.findUsers(any(), any()))
+        .thenReturn(new FilterResponse<>(List.of(pEntry3, pEntry4), 5));
+
+      ScimRequestContext context = new ScimRequestContext()
+        .setPageRequest(new PageRequest().setStartIndex(3).setCount(2));
+
+      FilterResponse<ScimUser> response = repository.find(null, context);
+
+      assertThat(response.getResources()).containsExactly(pUser3, pUser4);
+      assertThat(response.getTotalResults()).isEqualTo(5);
+    }
+
+    @Test
+    void paginationLastPage() throws Exception {
+      setupEntryMappings();
+
+      when(ldapDao.findUsers(any(), any()))
+        .thenReturn(new FilterResponse<>(List.of(pEntry5), 5));
+
+      ScimRequestContext context = new ScimRequestContext()
+        .setPageRequest(new PageRequest().setStartIndex(5).setCount(10));
+
+      FilterResponse<ScimUser> response = repository.find(null, context);
+
+      assertThat(response.getResources()).containsExactly(pUser5);
+      assertThat(response.getTotalResults()).isEqualTo(5);
+    }
+
+    @Test
+    void paginationBeyondResults() throws Exception {
+      setupEntryMappings();
+
+      when(ldapDao.findUsers(any(), any()))
+        .thenReturn(new FilterResponse<>(Collections.emptyList(), 5));
+
+      ScimRequestContext context = new ScimRequestContext()
+        .setPageRequest(new PageRequest().setStartIndex(6).setCount(2));
+
+      FilterResponse<ScimUser> response = repository.find(null, context);
+
+      assertThat(response.getResources()).isEmpty();
+      assertThat(response.getTotalResults()).isEqualTo(5);
+    }
+
+    @Test
+    void noPaginationReturnsAll() throws Exception {
+      setupEntryMappings();
+
+      when(ldapDao.findUsers(any(), any()))
+        .thenReturn(new FilterResponse<>(List.of(pEntry1, pEntry2, pEntry3, pEntry4, pEntry5), 5));
+
+      FilterResponse<ScimUser> response = repository.find(null, ScimRequestContext.empty());
+
+      assertThat(response.getResources()).containsExactly(pUser1, pUser2, pUser3, pUser4, pUser5);
+      assertThat(response.getTotalResults()).isEqualTo(5);
+    }
+
+    @Test
+    void resourceExceptionFromSearchPropagates() throws Exception {
+      when(ldapDao.findUsers(any(), any()))
+        .thenThrow(new ResourceException(503, "LDAP unavailable"));
+
+      assertThatThrownBy(() -> repository.find(null, ScimRequestContext.empty()))
+        .isInstanceOf(ResourceException.class)
+        .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(503));
+    }
+
+    @Test
+    void mappingExceptionWrapsIn500() throws Exception {
+      Entry entry = new DefaultEntry("uid=bad," + BASE_DN);
+      when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(List.of(entry), 1));
+      when(attributeMapper.toScimUser(entry)).thenThrow(new RuntimeException("bad attribute"));
+
+      assertThatThrownBy(() -> repository.find(null, ScimRequestContext.empty()))
+        .isInstanceOf(ResourceException.class)
+        .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(500));
+    }
+  }
+
+  // =========================================================================
+  // get
+  // =========================================================================
+
+  @Nested
+  @DisplayName("get")
+  class GetTest {
+
+    @Test
+    void foundReturnsScimUser() throws Exception {
+      Entry entry = new DefaultEntry("uid=alice," + BASE_DN);
+      ScimUser user = new ScimUser();
+      user.setId("uuid-1");
+
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "uuid-1")).thenReturn(entry);
+      when(attributeMapper.toScimUser(entry)).thenReturn(user);
+
+      ScimUser result = repository.get("uuid-1", ScimRequestContext.empty());
+
+      assertThat(result).isSameAs(user);
+    }
+
+    @Test
+    void notFoundReturnsNull() throws Exception {
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "missing")).thenReturn(null);
+
+      ScimUser result = repository.get("missing", ScimRequestContext.empty());
+
+      assertThat(result).isNull();
+    }
+
+    @Test
+    void resourceExceptionPropagates() throws Exception {
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "uuid-1"))
+        .thenThrow(new ResourceException(503, "LDAP unavailable"));
+
+      assertThatThrownBy(() -> repository.get("uuid-1", ScimRequestContext.empty()))
+        .isInstanceOf(ResourceException.class)
+        .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(503));
+    }
+  }
+
+  // =========================================================================
+  // create
+  // =========================================================================
+
+  @Nested
+  @DisplayName("create")
+  class CreateTest {
+
+    @Test
+    void successVerifiesChain() throws Exception {
+      ScimUser input = new ScimUser();
+      input.setUserName("alice");
+
+      Entry newEntry = new DefaultEntry("uid=alice," + BASE_DN);
+      Entry createdEntry = new DefaultEntry("uid=alice," + BASE_DN);
+      ScimUser createdUser = new ScimUser();
+      createdUser.setId("uuid-created");
+
+      when(attributeMapper.toEntry(input, BASE_DN)).thenReturn(newEntry);
+      when(ldapDao.lookup("uid=alice," + BASE_DN)).thenReturn(createdEntry);
+      when(attributeMapper.toScimUser(createdEntry)).thenReturn(createdUser);
+
+      ScimUser result = repository.create(input, ScimRequestContext.empty());
+
+      assertThat(result).isSameAs(createdUser);
+      verify(attributeMapper).toEntry(input, BASE_DN);
+      verify(ldapDao).create(newEntry);
+      verify(ldapDao).lookup("uid=alice," + BASE_DN);
+      verify(attributeMapper).toScimUser(createdEntry);
+    }
+
+    @Test
+    void resourceExceptionPropagates() throws Exception {
+      ScimUser input = new ScimUser();
+      input.setUserName("alice");
+
+      Entry newEntry = new DefaultEntry("uid=alice," + BASE_DN);
+      when(attributeMapper.toEntry(input, BASE_DN)).thenReturn(newEntry);
+      when(ldapDao.lookup(anyString())).thenThrow(new ResourceException(409, "Conflict"));
+
+      // ldapDao.create does not throw, but lookup does
+      assertThatThrownBy(() -> repository.create(input, ScimRequestContext.empty()))
+        .isInstanceOf(ResourceException.class)
+        .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(409));
+    }
+  }
+
+  // =========================================================================
+  // update
+  // =========================================================================
+
+  @Nested
+  @DisplayName("update")
+  class UpdateTest {
+
+    @Test
+    void foundVerifiesChain() throws Exception {
+      String id = "uuid-1";
+      ScimUser resource = new ScimUser();
+      resource.setUserName("alice");
+
+      Entry existingEntry = new DefaultEntry("uid=alice," + BASE_DN);
+      Entry updatedEntry = new DefaultEntry("uid=alice," + BASE_DN);
+      Entry resultEntry = new DefaultEntry("uid=alice," + BASE_DN);
+      ScimUser resultUser = new ScimUser();
+      resultUser.setId(id);
+
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(existingEntry);
+      when(attributeMapper.toEntry(resource, BASE_DN)).thenReturn(updatedEntry);
+      when(ldapDao.lookup("uid=alice," + BASE_DN)).thenReturn(resultEntry);
+      when(attributeMapper.toScimUser(resultEntry)).thenReturn(resultUser);
+
+      ScimUser result = repository.update(id, resource, ScimRequestContext.empty());
+
+      assertThat(result).isSameAs(resultUser);
+      verify(ldapDao).searchByAttribute(BASE_DN, "entryUUID", id);
+      verify(attributeMapper).toEntry(resource, BASE_DN);
+      verify(ldapDao).lookup("uid=alice," + BASE_DN);
+      verify(attributeMapper).toScimUser(resultEntry);
+    }
+
+    @Test
+    void notFoundThrowsResourceNotFoundException() throws Exception {
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "missing")).thenReturn(null);
+
+      assertThatThrownBy(() -> repository.update("missing", new ScimUser(), ScimRequestContext.empty()))
+        .isInstanceOf(ResourceNotFoundException.class);
+    }
+
+    @Test
+    void userNameChanged_callsRenameBeforeModify() throws Exception {
+      String id = "uuid-1";
+      ScimUser resource = new ScimUser();
+      resource.setUserName("new");
+
+      Entry existingEntry = new DefaultEntry("uid=old," + BASE_DN, "uid: old");
+      Entry updatedEntry = new DefaultEntry("uid=new," + BASE_DN);
+      Entry resultEntry = new DefaultEntry("uid=new," + BASE_DN);
+      ScimUser resultUser = new ScimUser();
+      resultUser.setId(id);
+
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(existingEntry);
+      when(attributeMapper.toEntry(resource, BASE_DN)).thenReturn(updatedEntry);
+      when(ldapDao.lookup(anyString())).thenReturn(resultEntry);
+      when(attributeMapper.toScimUser(resultEntry)).thenReturn(resultUser);
+
+      repository.update(id, resource, ScimRequestContext.empty());
+
+      verify(ldapDao).rename("uid=old," + BASE_DN, "uid=new");
+    }
+
+    @Test
+    void userNameUnchanged_doesNotCallRename() throws Exception {
+      String id = "uuid-1";
+      ScimUser resource = new ScimUser();
+      resource.setUserName("alice");
+
+      Entry existingEntry = new DefaultEntry("uid=alice," + BASE_DN, "uid: alice");
+      Entry updatedEntry = new DefaultEntry("uid=alice," + BASE_DN);
+      Entry resultEntry = new DefaultEntry("uid=alice," + BASE_DN);
+      ScimUser resultUser = new ScimUser();
+      resultUser.setId(id);
+
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(existingEntry);
+      when(attributeMapper.toEntry(resource, BASE_DN)).thenReturn(updatedEntry);
+      when(ldapDao.lookup("uid=alice," + BASE_DN)).thenReturn(resultEntry);
+      when(attributeMapper.toScimUser(resultEntry)).thenReturn(resultUser);
+
+      repository.update(id, resource, ScimRequestContext.empty());
+
+      verify(ldapDao, never()).rename(anyString(), anyString());
+    }
+
+    @Test
+    void userNameChanged_renameSucceeds_modifyFails_throwsResourceException() throws Exception {
+      String id = "uuid-1";
+      ScimUser resource = new ScimUser();
+      resource.setUserName("new");
+
+      Entry existingEntry = new DefaultEntry("uid=old," + BASE_DN, "uid: old");
+      // Include a non-RDN, non-objectClass attr so buildReplaceModifications produces a modification
+      Entry updatedEntry = new DefaultEntry("uid=new," + BASE_DN, "cn: New Name");
+
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(existingEntry);
+      when(attributeMapper.toEntry(resource, BASE_DN)).thenReturn(updatedEntry);
+      doThrow(new ResourceException(500, "LDAP modify failed"))
+        .when(ldapDao).modify(anyString(), any(Modification[].class));
+
+      assertThatThrownBy(() -> repository.update(id, resource, ScimRequestContext.empty()))
+        .isInstanceOf(ResourceException.class);
+    }
+  }
+
+  // =========================================================================
+  // delete
+  // =========================================================================
+
+  @Nested
+  @DisplayName("delete")
+  class DeleteTest {
+
+    @Test
+    void foundVerifiesDelete() throws Exception {
+      String id = "uuid-1";
+      Entry entry = new DefaultEntry("uid=alice," + BASE_DN);
+
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(entry);
+
+      repository.delete(id);
+
+      verify(ldapDao).searchByAttribute(BASE_DN, "entryUUID", id);
+      verify(ldapDao).delete("uid=alice," + BASE_DN);
+    }
+
+    @Test
+    void notFoundThrowsResourceNotFoundException() throws Exception {
+      when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "missing")).thenReturn(null);
+
+      assertThatThrownBy(() -> repository.delete("missing"))
+        .isInstanceOf(ResourceNotFoundException.class);
+    }
+  }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/resources/META-INF/services/org.apache.directory.scim.compliance.junit.EmbeddedServerExtension$ScimTestServer b/reference-projects/scim-server-ldap/src/test/resources/META-INF/services/org.apache.directory.scim.compliance.junit.EmbeddedServerExtension$ScimTestServer
new file mode 100644
index 0000000..016dcc4
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/resources/META-INF/services/org.apache.directory.scim.compliance.junit.EmbeddedServerExtension$ScimTestServer
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#  http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+org.apache.directory.scim.ldap.LdapTestServer
diff --git a/reference-projects/scim-server-ldap/src/test/resources/scim-ldap.yml b/reference-projects/scim-server-ldap/src/test/resources/scim-ldap.yml
new file mode 100644
index 0000000..f318661
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/resources/scim-ldap.yml
@@ -0,0 +1,64 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#  http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+# Test configuration for embedded Apache DS.
+# Adds extensibleObject so custom attributes (scimActive, scimPhoneTypes)
+# are accepted without schema registration.
+
+ldap:
+  embedded: false        # Tests use LdapTestServer which manages its own embedded instance
+  host: 127.0.0.1
+  port: 10389
+  bindDn: uid=admin,ou=system
+  bindPassword: secret
+  useTls: false
+  userBaseDn: ou=users,dc=example,dc=com
+  groupBaseDn: ou=groups,dc=example,dc=com
+
+user:
+  objectClasses:
+    - inetOrgPerson
+    - organizationalPerson
+    - person
+    - extensibleObject
+    - top
+  rdnAttribute: uid
+  attributes:
+    userName: uid
+    "name.givenName": givenName
+    "name.familyName": sn
+    "name.formatted": cn
+    displayName: displayName
+    "emails.value": mail
+    "phoneNumbers.value": telephoneNumber
+    "addresses.streetAddress": street
+    "addresses.locality": l
+    "addresses.postalCode": postalCode
+    title: title
+    userType: employeeType
+    password: userPassword
+
+group:
+  objectClasses:
+    - groupOfNames
+    - top
+  rdnAttribute: cn
+  attributes:
+    displayName: cn
+    "members.value": member
diff --git a/src/spotbugs/excludes.xml b/src/spotbugs/excludes.xml
index cd4597b..aead5e3 100644
--- a/src/spotbugs/excludes.xml
+++ b/src/spotbugs/excludes.xml
@@ -66,4 +66,13 @@
     <Bug pattern="CT_CONSTRUCTOR_THROW"/>
   </Match>
 
+  <!-- False positive: "password" and "userPassword" are SCIM/LDAP attribute names and config defaults, not credentials -->
+  <Match>
+    <Or>
+      <Class name="org.apache.directory.scim.ldap.mapping.AttributeMapper"/>
+      <Class name="org.apache.directory.scim.ldap.ldap.ScimLdapConfig"/>
+    </Or>
+    <Bug pattern="HARD_CODE_PASSWORD"/>
+  </Match>
+
 </FindBugsFilter>