diff --git a/src/main/java/sh/libre/scim/core/AbstractScimService.java b/src/main/java/sh/libre/scim/core/AbstractScimService.java
new file mode 100644
index 0000000000000000000000000000000000000000..11d6276084770ae16351d670a54270f8305b803c
--- /dev/null
+++ b/src/main/java/sh/libre/scim/core/AbstractScimService.java
@@ -0,0 +1,230 @@
+package sh.libre.scim.core;
+
+import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException;
+import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
+import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
+import org.jboss.logging.Logger;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RoleMapperModel;
+import org.keycloak.storage.user.SynchronizationResult;
+import sh.libre.scim.jpa.ScimResource;
+import sh.libre.scim.jpa.ScimResourceDao;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+public abstract class AbstractScimService<RMM extends RoleMapperModel, S extends ResourceNode> implements AutoCloseable {
+
+    private static final Logger LOGGER = Logger.getLogger(AbstractScimService.class);
+
+    private final KeycloakSession keycloakSession;
+
+    private final ScrimProviderConfiguration scimProviderConfiguration;
+
+    private final ScimResourceType type;
+
+    private final ScimClient<S> scimClient;
+
+    protected AbstractScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType type) {
+        this.keycloakSession = keycloakSession;
+        this.scimProviderConfiguration = scimProviderConfiguration;
+        this.type = type;
+        this.scimClient = ScimClient.open(scimProviderConfiguration, type);
+    }
+
+    public void create(RMM roleMapperModel) {
+        boolean skip = isSkip(roleMapperModel);
+        if (skip)
+            return;
+        // If mapping exist then it was created by import so skip.
+        KeycloakId id = getId(roleMapperModel);
+        if (findById(id).isPresent()) {
+            return;
+        }
+        ResourceNode scimForCreation = toScimForCreation(roleMapperModel);
+        EntityOnRemoteScimId externalId = scimClient.create(scimForCreation);
+        createMapping(id, externalId);
+    }
+
+    protected abstract ResourceNode toScimForCreation(RMM roleMapperModel);
+
+    protected abstract KeycloakId getId(RMM roleMapperModel);
+
+    protected abstract boolean isSkip(RMM roleMapperModel);
+
+    private void createMapping(KeycloakId keycloakId, EntityOnRemoteScimId externalId) {
+        getScimResourceDao().create(keycloakId, externalId, type);
+    }
+
+    protected ScimResourceDao getScimResourceDao() {
+        return ScimResourceDao.newInstance(getKeycloakSession(), scimProviderConfiguration.getId());
+    }
+
+    private Optional<ScimResource> findById(KeycloakId keycloakId) {
+        return getScimResourceDao().findById(keycloakId, type);
+    }
+
+    private KeycloakSession getKeycloakSession() {
+        return keycloakSession;
+    }
+
+    public void replace(RMM roleMapperModel) {
+        try {
+            if (isSkip(roleMapperModel))
+                return;
+            KeycloakId id = getId(roleMapperModel);
+            ScimResource scimResource = findById(id).get();
+            EntityOnRemoteScimId externalId = scimResource.getExternalIdAsEntityOnRemoteScimId();
+            ResourceNode scimForReplace = toScimForReplace(roleMapperModel, externalId);
+            scimClient.replace(externalId, scimForReplace);
+        } catch (NoSuchElementException e) {
+            LOGGER.warnf("failed to replace resource %s, scim mapping not found", getId(roleMapperModel));
+        } catch (Exception e) {
+            LOGGER.error(e);
+        }
+    }
+
+    protected abstract ResourceNode toScimForReplace(RMM roleMapperModel, EntityOnRemoteScimId externalId);
+
+    /**
+     * @deprecated use {@link #delete(KeycloakId)}
+     */
+    @Deprecated
+    public void delete(String id) {
+        delete(new KeycloakId(id));
+    }
+
+    public void delete(KeycloakId id) {
+        try {
+            ScimResource resource = findById(id).get();
+            EntityOnRemoteScimId externalId = resource.getExternalIdAsEntityOnRemoteScimId();
+            scimClient.delete(externalId);
+            getScimResourceDao().delete(resource);
+        } catch (NoSuchElementException e) {
+            LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id);
+        }
+    }
+
+    public void refreshResources(SynchronizationResult syncRes) {
+        LOGGER.info("Refresh resources");
+        getResourceStream().forEach(resource -> {
+            KeycloakId id = getId(resource);
+            LOGGER.infof("Reconciling local resource %s", id);
+            if (!isSkipRefresh(resource)) {
+                try {
+                    findById(id).get();
+                    LOGGER.info("Replacing it");
+                    replace(resource);
+                } catch (NoSuchElementException e) {
+                    LOGGER.info("Creating it");
+                    create(resource);
+                }
+                syncRes.increaseUpdated();
+            }
+        });
+    }
+
+    protected abstract boolean isSkipRefresh(RMM resource);
+
+    protected abstract Stream<RMM> getResourceStream();
+
+    public void importResources(SynchronizationResult syncRes) {
+        LOGGER.info("Import");
+        ScimClient<S> scimClient = this.scimClient;
+        try {
+            for (S resource : scimClient.listResources()) {
+                try {
+                    LOGGER.infof("Reconciling remote resource %s", resource);
+                    EntityOnRemoteScimId externalId = resource.getId().map(EntityOnRemoteScimId::new).get();
+                    try {
+                        ScimResource mapping = getScimResourceDao().findByExternalId(externalId, type).get();
+                        if (entityExists(mapping.getIdAsKeycloakId())) {
+                            LOGGER.info("Valid mapping found, skipping");
+                            continue;
+                        } else {
+                            LOGGER.info("Delete a dangling mapping");
+                            getScimResourceDao().delete(mapping);
+                        }
+                    } catch (NoSuchElementException e) {
+                        // nothing to do
+                    }
+
+                    Optional<KeycloakId> mapped = tryToMap(resource);
+                    if (mapped.isPresent()) {
+                        LOGGER.info("Matched");
+                        createMapping(mapped.get(), externalId);
+                    } else {
+                        switch (scimProviderConfiguration.getImportAction()) {
+                            case CREATE_LOCAL:
+                                LOGGER.info("Create local resource");
+                                try {
+                                    KeycloakId id = createEntity(resource);
+                                    createMapping(id, externalId);
+                                    syncRes.increaseAdded();
+                                } catch (Exception e) {
+                                    LOGGER.error(e);
+                                }
+                                break;
+                            case DELETE_REMOTE:
+                                LOGGER.info("Delete remote resource");
+                                scimClient.delete(externalId);
+                                syncRes.increaseRemoved();
+                                break;
+                            case NOTHING:
+                                LOGGER.info("Import action set to NOTHING");
+                                break;
+                        }
+                    }
+                } catch (Exception e) {
+                    LOGGER.error(e);
+                    e.printStackTrace();
+                    syncRes.increaseFailed();
+                }
+            }
+        } catch (ResponseException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    protected abstract KeycloakId createEntity(S resource);
+
+    protected abstract Optional<KeycloakId> tryToMap(S resource);
+
+    protected abstract boolean entityExists(KeycloakId keycloakId);
+
+    public void sync(SynchronizationResult syncRes) {
+        if (this.scimProviderConfiguration.isSyncImport()) {
+            this.importResources(syncRes);
+        }
+        if (this.scimProviderConfiguration.isSyncRefresh()) {
+            this.refreshResources(syncRes);
+        }
+    }
+
+    protected Meta newMetaLocation(EntityOnRemoteScimId externalId) {
+        Meta meta = new Meta();
+        try {
+            URI uri = new URI("%ss/%s".formatted(type, externalId.asString()));
+            meta.setLocation(uri.toString());
+        } catch (URISyntaxException e) {
+            LOGGER.warn(e);
+        }
+        return meta;
+    }
+
+    protected KeycloakDao getKeycloakDao() {
+        return new KeycloakDao(getKeycloakSession());
+    }
+
+    @Override
+    public void close() {
+        scimClient.close();
+    }
+
+    public ScrimProviderConfiguration getConfiguration() {
+        return scimProviderConfiguration;
+    }
+}
diff --git a/src/main/java/sh/libre/scim/core/Adapter.java b/src/main/java/sh/libre/scim/core/Adapter.java
deleted file mode 100644
index 607a7e137c3ce4b1252d308cd8d2af7e2c3b4c46..0000000000000000000000000000000000000000
--- a/src/main/java/sh/libre/scim/core/Adapter.java
+++ /dev/null
@@ -1,137 +0,0 @@
-package sh.libre.scim.core;
-
-import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.NoResultException;
-import jakarta.persistence.TypedQuery;
-import org.jboss.logging.Logger;
-import org.keycloak.connections.jpa.JpaConnectionProvider;
-import org.keycloak.models.KeycloakSession;
-import org.keycloak.models.RealmModel;
-import org.keycloak.models.RoleMapperModel;
-import sh.libre.scim.jpa.ScimResource;
-
-import java.util.stream.Stream;
-
-/**
- * Abstract class for converting a Keycloack {@link RoleMapperModel} into a SCIM {@link ResourceNode}.
- *
- * @param <M> The Keycloack {@link RoleMapperModel} (e.g. GroupModel, UserModel)
- * @param <S> the SCIM {@link ResourceNode} (e.g. Group, User)
- */
-public abstract class Adapter<M extends RoleMapperModel, S extends ResourceNode> {
-
-    protected final Logger logger;
-    protected final String realmId;
-    protected final RealmModel realm;
-    protected final String type;
-    protected final String componentId;
-    protected final EntityManager em;
-    protected final KeycloakSession session;
-
-    protected String id;
-    protected String externalId;
-    protected Boolean skip = false;
-
-    public Adapter(KeycloakSession session, String componentId, String type, Logger logger) {
-        this.session = session;
-        this.realm = session.getContext().getRealm();
-        this.realmId = session.getContext().getRealm().getId();
-        this.componentId = componentId;
-        this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
-        this.type = type;
-        this.logger = logger;
-    }
-
-    public String getId() {
-        return id;
-    }
-
-    public void setId(String id) {
-        if (this.id == null) {
-            this.id = id;
-        }
-    }
-
-    public String getExternalId() {
-        return externalId;
-    }
-
-    public void setExternalId(String externalId) {
-        if (this.externalId == null) {
-            this.externalId = externalId;
-        }
-    }
-
-    public String getScimEndpoint() {
-        return "/" + type + "s";
-    }
-
-    public ScimResource toMapping() {
-        ScimResource entity = new ScimResource();
-        entity.setType(type);
-        entity.setId(id);
-        entity.setExternalId(externalId);
-        entity.setComponentId(componentId);
-        entity.setRealmId(realmId);
-        return entity;
-    }
-
-    public TypedQuery<ScimResource> query(String query, String id) {
-        return query(query, id, type);
-    }
-
-    public TypedQuery<ScimResource> query(String query, String id, String type) {
-        return this.em
-                .createNamedQuery(query, ScimResource.class)
-                .setParameter("type", type)
-                .setParameter("realmId", realmId)
-                .setParameter("componentId", componentId)
-                .setParameter("id", id);
-    }
-
-    public ScimResource getMapping() {
-        try {
-            if (this.id != null) {
-                return this.query("findById", id).getSingleResult();
-            }
-            if (this.externalId != null) {
-                return this.query("findByExternalId", externalId).getSingleResult();
-            }
-        } catch (NoResultException e) {
-        }
-        return null;
-    }
-
-    public void saveMapping() {
-        this.em.persist(toMapping());
-    }
-
-    public void deleteMapping() {
-        ScimResource mapping = this.em.merge(toMapping());
-        this.em.remove(mapping);
-    }
-
-    public void apply(ScimResource mapping) {
-        setId(mapping.getId());
-        setExternalId(mapping.getExternalId());
-    }
-
-    public abstract void apply(M model);
-
-    public abstract void apply(S resource);
-
-    public abstract Class<S> getResourceClass();
-
-    public abstract S toScim();
-
-    public abstract Boolean entityExists();
-
-    public abstract Boolean tryToMap();
-
-    public abstract void createEntity() throws Exception;
-
-    public abstract Stream<M> getResourceStream();
-
-    public abstract Boolean skipRefresh();
-}
diff --git a/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java b/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java
new file mode 100644
index 0000000000000000000000000000000000000000..3249608baa3a9f997d8760f805ce77df04fb6b9c
--- /dev/null
+++ b/src/main/java/sh/libre/scim/core/EntityOnRemoteScimId.java
@@ -0,0 +1,6 @@
+package sh.libre.scim.core;
+
+public record EntityOnRemoteScimId(
+        String asString
+) {
+}
diff --git a/src/main/java/sh/libre/scim/core/GroupAdapter.java b/src/main/java/sh/libre/scim/core/GroupAdapter.java
deleted file mode 100644
index b87bf30b68ec12c17c7a7b396c7c41bd3ff3f663..0000000000000000000000000000000000000000
--- a/src/main/java/sh/libre/scim/core/GroupAdapter.java
+++ /dev/null
@@ -1,160 +0,0 @@
-package sh.libre.scim.core;
-
-import de.captaingoldfish.scim.sdk.common.resources.Group;
-import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
-import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member;
-import jakarta.persistence.NoResultException;
-import org.apache.commons.collections4.CollectionUtils;
-import org.apache.commons.lang3.BooleanUtils;
-import org.jboss.logging.Logger;
-import org.keycloak.models.GroupModel;
-import org.keycloak.models.KeycloakSession;
-import org.keycloak.models.UserModel;
-import sh.libre.scim.jpa.ScimResource;
-
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-public class GroupAdapter extends Adapter<GroupModel, Group> {
-
-    private String displayName;
-    private Set<String> members = new HashSet<>();
-
-    public GroupAdapter(KeycloakSession session, String componentId) {
-        super(session, componentId, "Group", Logger.getLogger(GroupAdapter.class));
-    }
-
-    public String getDisplayName() {
-        return displayName;
-    }
-
-    public void setDisplayName(String displayName) {
-        if (this.displayName == null) {
-            this.displayName = displayName;
-        }
-    }
-
-    @Override
-    public Class<Group> getResourceClass() {
-        return Group.class;
-    }
-
-    @Override
-    public void apply(GroupModel group) {
-        setId(group.getId());
-        setDisplayName(group.getName());
-        this.members = session.users()
-                .getGroupMembersStream(session.getContext().getRealm(), group)
-                .map(x -> x.getId())
-                .collect(Collectors.toSet());
-        this.skip = BooleanUtils.TRUE.equals(group.getFirstAttribute("scim-skip"));
-    }
-
-    @Override
-    public void apply(Group group) {
-        setExternalId(group.getId().get());
-        setDisplayName(group.getDisplayName().get());
-        List<Member> groupMembers = group.getMembers();
-        if (CollectionUtils.isNotEmpty(groupMembers)) {
-            this.members = new HashSet<>();
-            for (Member groupMember : groupMembers) {
-                try {
-                    ScimResource userMapping = this.query("findByExternalId", groupMember.getValue().get(), "User")
-                            .getSingleResult();
-                    this.members.add(userMapping.getId());
-                } catch (NoResultException e) {
-                    logger.warnf("member %s not found for scim group %s", groupMember.getValue().get(), group.getId().get());
-                }
-            }
-        }
-    }
-
-    @Override
-    public Group toScim() {
-        Group group = new Group();
-        group.setId(externalId);
-        group.setExternalId(id);
-        group.setDisplayName(displayName);
-        for (String member : members) {
-            Member groupMember = new Member();
-            try {
-                ScimResource userMapping = this.query("findById", member, "User").getSingleResult();
-                logger.debug(userMapping.getExternalId());
-                logger.debug(userMapping.getId());
-                groupMember.setValue(userMapping.getExternalId());
-                URI ref = new URI(String.format("Users/%s", userMapping.getExternalId()));
-                groupMember.setRef(ref.toString());
-                group.addMember(groupMember);
-            } catch (NoResultException e) {
-                logger.warnf("member %s not found for group %s", member, id);
-            } catch (URISyntaxException e) {
-                logger.warnf("bad ref uri");
-            }
-        }
-        Meta meta = new Meta();
-        try {
-            URI uri = new URI("Groups/" + externalId);
-            meta.setLocation(uri.toString());
-        } catch (URISyntaxException e) {
-            logger.warn(e);
-        }
-        group.setMeta(meta);
-        return group;
-    }
-
-    @Override
-    public Boolean entityExists() {
-        if (this.id == null) {
-            return false;
-        }
-        GroupModel group = session.groups().getGroupById(realm, id);
-        return group != null;
-    }
-
-    @Override
-    public Boolean tryToMap() {
-        Set<String> names = Set.of(externalId, displayName);
-        Optional<GroupModel> group = session.groups().getGroupsStream(realm)
-                .filter(groupModel -> names.contains(groupModel.getName()))
-                .findFirst();
-        if (group.isPresent()) {
-            setId(group.get().getId());
-            return true;
-        }
-        return false;
-    }
-
-    @Override
-    public void createEntity() {
-        GroupModel group = session.groups().createGroup(realm, displayName);
-        this.id = group.getId();
-        for (String mId : members) {
-            try {
-                UserModel user = session.users().getUserById(realm, mId);
-                if (user == null) {
-                    throw new NoResultException();
-                }
-                user.joinGroup(group);
-            } catch (Exception e) {
-                logger.warn(e);
-            }
-        }
-    }
-
-    @Override
-    public Stream<GroupModel> getResourceStream() {
-        return this.session.groups().getGroupsStream(this.session.getContext().getRealm());
-    }
-
-    @Override
-    public Boolean skipRefresh() {
-        return false;
-    }
-
-}
diff --git a/src/main/java/sh/libre/scim/core/GroupScimClient.java b/src/main/java/sh/libre/scim/core/GroupScimClient.java
deleted file mode 100644
index c1f09e1c2895e232d04a7da49d1b60a1716710b9..0000000000000000000000000000000000000000
--- a/src/main/java/sh/libre/scim/core/GroupScimClient.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package sh.libre.scim.core;
-
-import org.keycloak.component.ComponentModel;
-import org.keycloak.models.GroupModel;
-import org.keycloak.models.KeycloakSession;
-import org.keycloak.storage.user.SynchronizationResult;
-
-public class GroupScimClient implements ScimClientInterface<GroupModel> {
-    public static GroupScimClient newGroupScimClient(ComponentModel scimServer, KeycloakSession session) {
-        return new GroupScimClient();
-    }
-
-    @Override
-    public void create(GroupModel resource) {
-        throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public void replace(GroupModel resource) {
-        throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public void delete(String id) {
-        throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public void sync(SynchronizationResult result) {
-        throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public ScrimProviderConfiguration getConfiguration() {
-        throw new UnsupportedOperationException();
-    }
-
-    @Override
-    public void close() throws Exception {
-        
-    }
-}
diff --git a/src/main/java/sh/libre/scim/core/GroupScimService.java b/src/main/java/sh/libre/scim/core/GroupScimService.java
new file mode 100644
index 0000000000000000000000000000000000000000..0648f10c106c3f7c918ad83d8fb743aef3d5fd2c
--- /dev/null
+++ b/src/main/java/sh/libre/scim/core/GroupScimService.java
@@ -0,0 +1,127 @@
+package sh.libre.scim.core;
+
+import de.captaingoldfish.scim.sdk.common.resources.Group;
+import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
+import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Member;
+import jakarta.persistence.NoResultException;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.BooleanUtils;
+import org.jboss.logging.Logger;
+import org.keycloak.models.GroupModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.UserModel;
+import sh.libre.scim.jpa.ScimResource;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+
+public class GroupScimService extends AbstractScimService<GroupModel, Group> {
+    private final Logger logger = Logger.getLogger(GroupScimService.class);
+
+    public GroupScimService(KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) {
+        super(keycloakSession, scimProviderConfiguration, ScimResourceType.GROUP);
+    }
+
+    @Override
+    protected Stream<GroupModel> getResourceStream() {
+        return getKeycloakDao().getGroupsStream();
+    }
+
+    @Override
+    protected boolean entityExists(KeycloakId keycloakId) {
+        return getKeycloakDao().groupExists(keycloakId);
+    }
+
+    @Override
+    protected Optional<KeycloakId> tryToMap(Group resource) {
+        String externalId = resource.getId().get();
+        String displayName = resource.getDisplayName().get();
+        Set<String> names = Set.of(externalId, displayName);
+        Optional<GroupModel> group = getKeycloakDao().getGroupsStream()
+                .filter(groupModel -> names.contains(groupModel.getName()))
+                .findFirst();
+        return group.map(GroupModel::getId).map(KeycloakId::new);
+    }
+
+    @Override
+    protected KeycloakId createEntity(Group resource) {
+        String displayName = resource.getDisplayName().get();
+        GroupModel group = getKeycloakDao().createGroup(displayName);
+        List<Member> groupMembers = resource.getMembers();
+        if (CollectionUtils.isNotEmpty(groupMembers)) {
+            for (Member groupMember : groupMembers) {
+                try {
+                    EntityOnRemoteScimId externalId = new EntityOnRemoteScimId(groupMember.getValue().get());
+                    ScimResource userMapping = getScimResourceDao().findUserByExternalId(externalId).get();
+                    KeycloakId userId = userMapping.getIdAsKeycloakId();
+                    try {
+                        UserModel user = getKeycloakDao().getUserById(userId);
+                        if (user == null) {
+                            throw new NoResultException();
+                        }
+                        user.joinGroup(group);
+                    } catch (Exception e) {
+                        logger.warn(e);
+                    }
+                } catch (NoSuchElementException e) {
+                    logger.warnf("member %s not found for scim group %s", groupMember.getValue().get(), resource.getId().get());
+                }
+            }
+        }
+        return new KeycloakId(group.getId());
+    }
+
+    @Override
+    protected boolean isSkip(GroupModel groupModel) {
+        return BooleanUtils.TRUE.equals(groupModel.getFirstAttribute("scim-skip"));
+    }
+
+    @Override
+    protected KeycloakId getId(GroupModel groupModel) {
+        return new KeycloakId(groupModel.getId());
+    }
+
+    @Override
+    protected Group toScimForCreation(GroupModel groupModel) {
+        Set<KeycloakId> members = getKeycloakDao().getGroupMembers(groupModel);
+        Group group = new Group();
+        group.setExternalId(groupModel.getId());
+        group.setDisplayName(groupModel.getName());
+        for (KeycloakId member : members) {
+            Member groupMember = new Member();
+            try {
+                ScimResource userMapping = getScimResourceDao().findUserById(member).get();
+                logger.debug(userMapping.getExternalIdAsEntityOnRemoteScimId());
+                logger.debug(userMapping.getIdAsKeycloakId());
+                groupMember.setValue(userMapping.getExternalIdAsEntityOnRemoteScimId().asString());
+                URI ref = new URI(String.format("Users/%s", userMapping.getExternalIdAsEntityOnRemoteScimId()));
+                groupMember.setRef(ref.toString());
+                group.addMember(groupMember);
+            } catch (NoSuchElementException e) {
+                logger.warnf("member %s not found for group %s", member, groupModel.getId());
+            } catch (URISyntaxException e) {
+                logger.warnf("bad ref uri");
+            }
+        }
+        return group;
+    }
+
+    @Override
+    protected Group toScimForReplace(GroupModel groupModel, EntityOnRemoteScimId externalId) {
+        Group group = toScimForCreation(groupModel);
+        group.setId(externalId.asString());
+        Meta meta = newMetaLocation(externalId);
+        group.setMeta(meta);
+        return group;
+    }
+
+    @Override
+    protected boolean isSkipRefresh(GroupModel resource) {
+        return false;
+    }
+}
diff --git a/src/main/java/sh/libre/scim/core/KeycloakDao.java b/src/main/java/sh/libre/scim/core/KeycloakDao.java
new file mode 100644
index 0000000000000000000000000000000000000000..1062e03e703d1fe7f30f122905378acf46a2d92a
--- /dev/null
+++ b/src/main/java/sh/libre/scim/core/KeycloakDao.java
@@ -0,0 +1,74 @@
+package sh.libre.scim.core;
+
+import org.keycloak.models.GroupModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class KeycloakDao {
+
+    private final KeycloakSession keycloakSession;
+
+    public KeycloakDao(KeycloakSession keycloakSession) {
+        this.keycloakSession = keycloakSession;
+    }
+
+    private KeycloakSession getKeycloakSession() {
+        return keycloakSession;
+    }
+
+    private RealmModel getRealm() {
+        return getKeycloakSession().getContext().getRealm();
+    }
+
+    public boolean groupExists(KeycloakId groupId) {
+        GroupModel group = getKeycloakSession().groups().getGroupById(getRealm(), groupId.asString());
+        return group != null;
+    }
+
+    public boolean userExists(KeycloakId userId) {
+        UserModel user = getUserById(userId);
+        return user != null;
+    }
+
+    public UserModel getUserById(KeycloakId userId) {
+        return getKeycloakSession().users().getUserById(getRealm(), userId.asString());
+    }
+
+    public Stream<GroupModel> getGroupsStream() {
+        return getKeycloakSession().groups().getGroupsStream(getRealm());
+    }
+
+    public GroupModel createGroup(String displayName) {
+        return getKeycloakSession().groups().createGroup(getRealm(), displayName);
+    }
+
+    public Set<KeycloakId> getGroupMembers(GroupModel groupModel) {
+        return getKeycloakSession().users()
+                .getGroupMembersStream(getRealm(), groupModel)
+                .map(UserModel::getId)
+                .map(KeycloakId::new)
+                .collect(Collectors.toSet());
+    }
+
+    public Stream<UserModel> getUsersStream() {
+        return getKeycloakSession().users().searchForUserStream(getRealm(), Collections.emptyMap());
+    }
+
+    public UserModel getUserByUsername(String username) {
+        return getKeycloakSession().users().getUserByUsername(getRealm(), username);
+    }
+
+    public UserModel getUserByEmail(String email) {
+        return getKeycloakSession().users().getUserByEmail(getRealm(), email);
+    }
+
+    public UserModel addUser(String username) {
+        return getKeycloakSession().users().addUser(getRealm(), username);
+    }
+}
diff --git a/src/main/java/sh/libre/scim/core/KeycloakId.java b/src/main/java/sh/libre/scim/core/KeycloakId.java
new file mode 100644
index 0000000000000000000000000000000000000000..f35817dfbe97bb5232bca9784eabf6b7eb9d7564
--- /dev/null
+++ b/src/main/java/sh/libre/scim/core/KeycloakId.java
@@ -0,0 +1,7 @@
+package sh.libre.scim.core;
+
+public record KeycloakId(
+        String asString
+) {
+
+}
diff --git a/src/main/java/sh/libre/scim/core/ScimClient.java b/src/main/java/sh/libre/scim/core/ScimClient.java
index 071251bae0110c44d85b223a603c40acc2cfe496..ecbdb8d822760ea5ca917b52ec5ffbce74674b41 100644
--- a/src/main/java/sh/libre/scim/core/ScimClient.java
+++ b/src/main/java/sh/libre/scim/core/ScimClient.java
@@ -3,7 +3,6 @@ package sh.libre.scim.core;
 import com.google.common.net.HttpHeaders;
 import de.captaingoldfish.scim.sdk.client.ScimClientConfig;
 import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder;
-import de.captaingoldfish.scim.sdk.client.http.BasicAuth;
 import de.captaingoldfish.scim.sdk.client.response.ServerResponse;
 import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException;
 import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
@@ -12,300 +11,128 @@ import io.github.resilience4j.core.IntervalFunction;
 import io.github.resilience4j.retry.Retry;
 import io.github.resilience4j.retry.RetryConfig;
 import io.github.resilience4j.retry.RetryRegistry;
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.NoResultException;
-import jakarta.persistence.TypedQuery;
 import jakarta.ws.rs.ProcessingException;
 import org.jboss.logging.Logger;
-import org.keycloak.component.ComponentModel;
-import org.keycloak.connections.jpa.JpaConnectionProvider;
-import org.keycloak.models.KeycloakSession;
-import org.keycloak.models.RoleMapperModel;
-import org.keycloak.storage.user.SynchronizationResult;
-import sh.libre.scim.jpa.ScimResource;
 
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
+public class ScimClient<S extends ResourceNode> implements AutoCloseable {
+    private final Logger logger = Logger.getLogger(ScimClient.class);
 
-public class ScimClient implements AutoCloseable {
-
-    private static final Logger LOGGER = Logger.getLogger(ScimClient.class);
+    private final RetryRegistry retryRegistry;
 
     private final ScimRequestBuilder scimRequestBuilder;
 
-    private final RetryRegistry registry;
-
-    private final KeycloakSession session;
+    private final ScimResourceType scimResourceType;
 
-    private final ComponentModel model;
+    {
+        RetryConfig retryConfig = RetryConfig.custom()
+                .maxAttempts(10)
+                .intervalFunction(IntervalFunction.ofExponentialBackoff())
+                .retryExceptions(ProcessingException.class)
+                .build();
+        retryRegistry = RetryRegistry.of(retryConfig);
+    }
 
-    private ScimClient(ScimRequestBuilder scimRequestBuilder, RetryRegistry registry, KeycloakSession session, ComponentModel model) {
+    private ScimClient(ScimRequestBuilder scimRequestBuilder, ScimResourceType scimResourceType) {
         this.scimRequestBuilder = scimRequestBuilder;
-        this.registry = registry;
-        this.session = session;
-        this.model = model;
+        this.scimResourceType = scimResourceType;
     }
 
-    public static ScimClient newScimClient(ComponentModel model, KeycloakSession session) {
-        String authMode = model.get("auth-mode");
-        String authorizationHeaderValue = switch (authMode) {
-            case "BEARER" -> "Bearer " + model.get("auth-pass");
-            case "BASIC_AUTH" -> {
-                BasicAuth basicAuth = BasicAuth.builder()
-                        .username(model.get("auth-user"))
-                        .password(model.get("auth-pass"))
-                        .build();
-                yield basicAuth.getAuthorizationHeaderValue();
-            }
-            default -> throw new IllegalArgumentException("authMode " + authMode + " is not supported");
-        };
-
+    public static ScimClient open(ScrimProviderConfiguration scimProviderConfiguration, ScimResourceType scimResourceType) {
+        String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint();
         Map<String, String> httpHeaders = new HashMap<>();
-        httpHeaders.put(HttpHeaders.AUTHORIZATION, authorizationHeaderValue);
-        httpHeaders.put(HttpHeaders.CONTENT_TYPE, model.get("content-type"));
-
+        httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue());
+        httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType());
         ScimClientConfig scimClientConfig = ScimClientConfig.builder()
                 .httpHeaders(httpHeaders)
                 .connectTimeout(5)
                 .requestTimeout(5)
                 .socketTimeout(5)
                 .expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful?
-                .hostnameVerifier((s, sslSession) -> true)
+                // TODO Question Indiehoster : should we really allow connection with TLS ? .hostnameVerifier((s, sslSession) -> true)
                 .build();
-
-        String scimApplicationBaseUrl = model.get("endpoint");
         ScimRequestBuilder scimRequestBuilder =
                 new ScimRequestBuilder(
                         scimApplicationBaseUrl,
                         scimClientConfig
                 );
-
-        RetryConfig retryConfig = RetryConfig.custom()
-                .maxAttempts(10)
-                .intervalFunction(IntervalFunction.ofExponentialBackoff())
-                .retryExceptions(ProcessingException.class)
-                .build();
-
-        RetryRegistry retryRegistry = RetryRegistry.of(retryConfig);
-        return new ScimClient(scimRequestBuilder, retryRegistry, session, model);
-    }
-
-    private static <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> TypedQuery<ScimResource> findById(A adapter) {
-        return adapter.query("findById", adapter.getId());
-    }
-
-    protected EntityManager getEntityManager() {
-        return session.getProvider(JpaConnectionProvider.class).getEntityManager();
-    }
-
-    protected String getRealmId() {
-        return session.getContext().getRealm().getId();
+        return new ScimClient(scimRequestBuilder, scimResourceType);
     }
 
-    protected <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> A newAdapter(
-            Class<A> adapterClass) {
-        try {
-            return adapterClass.getDeclaredConstructor(KeycloakSession.class, String.class)
-                    .newInstance(session, this.model.getId());
-        } catch (Exception e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void create(Class<A> adapterClass,
-                                                                                                    M kcModel) {
-        A adapter = newAdapter(adapterClass);
-        adapter.apply(kcModel);
-        if (adapter.skip)
-            return;
-        // If mapping exist then it was created by import so skip.
-        if (!findById(adapter).getResultList().isEmpty()) {
-            return;
-        }
-        Retry retry = registry.retry("create-" + adapter.getId());
-
+    public EntityOnRemoteScimId create(ResourceNode scimForCreation) {
+        Retry retry = retryRegistry.retry("create-" + scimForCreation.getId().get());
         ServerResponse<S> response = retry.executeSupplier(() -> {
             try {
                 return scimRequestBuilder
-                        .create(adapter.getResourceClass(), adapter.getScimEndpoint())
-                        .setResource(adapter.toScim())
+                        .create(getResourceClass(), getScimEndpoint())
+                        .setResource(scimForCreation)
                         .sendRequest();
             } catch (ResponseException e) {
                 throw new RuntimeException(e);
             }
         });
+        checkResponseIsSuccess(response);
+        S resource = response.getResource();
+        return resource.getId()
+                .map(EntityOnRemoteScimId::new)
+                .orElseThrow(() -> new IllegalStateException("created resource does not have id"));
+    }
 
+    private void checkResponseIsSuccess(ServerResponse<S> response) {
         if (!response.isSuccess()) {
-            LOGGER.warn(response.getResponseBody());
-            LOGGER.warn(response.getHttpStatus());
+            logger.warn(response.getResponseBody());
+            logger.warn(response.getHttpStatus());
         }
-
-        adapter.apply(response.getResource());
-        adapter.saveMapping();
     }
 
-    public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void replace(Class<A> adapterClass,
-                                                                                                     M kcModel) {
-        A adapter = newAdapter(adapterClass);
-        try {
-            adapter.apply(kcModel);
-            if (adapter.skip)
-                return;
-            ScimResource resource = findById(adapter).getSingleResult();
-            adapter.apply(resource);
-            Retry retry = registry.retry("replace-" + adapter.getId());
-            ServerResponse<S> response = retry.executeSupplier(() -> {
-                try {
-                    return scimRequestBuilder
-                            .update(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId())
-                            .setResource(adapter.toScim())
-                            .sendRequest();
-                } catch (ResponseException e) {
-                    throw new RuntimeException(e);
-                }
-            });
-            if (!response.isSuccess()) {
-                LOGGER.warn(response.getResponseBody());
-                LOGGER.warn(response.getHttpStatus());
-            }
-        } catch (NoResultException e) {
-            LOGGER.warnf("failed to replace resource %s, scim mapping not found", adapter.getId());
-        } catch (Exception e) {
-            LOGGER.error(e);
-        }
+    private String getScimEndpoint() {
+        return scimResourceType.getEndpoint();
     }
 
-    public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void delete(Class<A> adapterClass,
-                                                                                                    String id) {
-        A adapter = newAdapter(adapterClass);
-        adapter.setId(id);
-
-        try {
-            ScimResource resource = findById(adapter).getSingleResult();
-            adapter.apply(resource);
-
-            Retry retry = registry.retry("delete-" + id);
-
-            ServerResponse<S> response = retry.executeSupplier(() -> {
-                try {
-                    return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId())
-                            .sendRequest();
-                } catch (ResponseException e) {
-                    throw new RuntimeException(e);
-                }
-            });
-
-            if (!response.isSuccess()) {
-                LOGGER.warn(response.getResponseBody());
-                LOGGER.warn(response.getHttpStatus());
-            }
-
-            getEntityManager().remove(resource);
-
-        } catch (NoResultException e) {
-            LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id);
-        }
+    private Class<S> getResourceClass() {
+        return scimResourceType.getResourceClass();
     }
 
-    public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void refreshResources(
-            Class<A> adapterClass,
-            SynchronizationResult syncRes) {
-        LOGGER.info("Refresh resources");
-        newAdapter(adapterClass).getResourceStream().forEach(resource -> {
-            A adapter = newAdapter(adapterClass);
-            adapter.apply(resource);
-            LOGGER.infof("Reconciling local resource %s", adapter.getId());
-            if (!adapter.skipRefresh()) {
-                ScimResource mapping = adapter.getMapping();
-                if (mapping == null) {
-                    LOGGER.info("Creating it");
-                    this.create(adapterClass, resource);
-                } else {
-                    LOGGER.info("Replacing it");
-                    this.replace(adapterClass, resource);
-                }
-                syncRes.increaseUpdated();
+    public void replace(EntityOnRemoteScimId externalId, ResourceNode scimForReplace) {
+        Retry retry = retryRegistry.retry("replace-" + scimForReplace.getId().get());
+        ServerResponse<S> response = retry.executeSupplier(() -> {
+            try {
+                return scimRequestBuilder
+                        .update(getResourceClass(), getScimEndpoint(), externalId.asString())
+                        .setResource(scimForReplace)
+                        .sendRequest();
+            } catch (ResponseException e) {
+                throw new RuntimeException(e);
             }
         });
-
+        checkResponseIsSuccess(response);
     }
 
-    public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void importResources(
-            Class<A> adapterClass, SynchronizationResult syncRes) {
-        LOGGER.info("Import");
-        try {
-            A adapter = newAdapter(adapterClass);
-            ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest();
-            ListResponse<S> resourceTypeListResponse = response.getResource();
-
-            for (S resource : resourceTypeListResponse.getListedResources()) {
-                try {
-                    LOGGER.infof("Reconciling remote resource %s", resource);
-                    adapter = newAdapter(adapterClass);
-                    adapter.apply(resource);
-
-                    ScimResource mapping = adapter.getMapping();
-                    if (mapping != null) {
-                        adapter.apply(mapping);
-                        if (adapter.entityExists()) {
-                            LOGGER.info("Valid mapping found, skipping");
-                            continue;
-                        } else {
-                            LOGGER.info("Delete a dangling mapping");
-                            adapter.deleteMapping();
-                        }
-                    }
-
-                    Boolean mapped = adapter.tryToMap();
-                    if (mapped) {
-                        LOGGER.info("Matched");
-                        adapter.saveMapping();
-                    } else {
-                        switch (this.model.get("sync-import-action")) {
-                            case "CREATE_LOCAL":
-                                LOGGER.info("Create local resource");
-                                try {
-                                    adapter.createEntity();
-                                    adapter.saveMapping();
-                                    syncRes.increaseAdded();
-                                } catch (Exception e) {
-                                    LOGGER.error(e);
-                                }
-                                break;
-                            case "DELETE_REMOTE":
-                                LOGGER.info("Delete remote resource");
-                                scimRequestBuilder
-                                        .delete(adapter.getResourceClass(), adapter.getScimEndpoint(), resource.getId().get())
-                                        .sendRequest();
-                                syncRes.increaseRemoved();
-                                break;
-                        }
-                    }
-                } catch (Exception e) {
-                    LOGGER.error(e);
-                    e.printStackTrace();
-                    syncRes.increaseFailed();
-                }
+    public void delete(EntityOnRemoteScimId externalId) {
+        Retry retry = retryRegistry.retry("delete-" + externalId.asString());
+        ServerResponse<S> response = retry.executeSupplier(() -> {
+            try {
+                return scimRequestBuilder.delete(getResourceClass(), getScimEndpoint(), externalId.asString())
+                        .sendRequest();
+            } catch (ResponseException e) {
+                throw new RuntimeException(e);
             }
-        } catch (ResponseException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    public <M extends RoleMapperModel, S extends ResourceNode, A extends Adapter<M, S>> void sync(Class<A> aClass,
-                                                                                                  SynchronizationResult syncRes) {
-        if (this.model.get("sync-import", false)) {
-            this.importResources(aClass, syncRes);
-        }
-        if (this.model.get("sync-refresh", false)) {
-            this.refreshResources(aClass, syncRes);
-        }
+        });
+        checkResponseIsSuccess(response);
     }
 
     @Override
     public void close() {
         scimRequestBuilder.close();
     }
+
+    public List<S> listResources() {
+        ServerResponse<ListResponse<S>> response = scimRequestBuilder.list(getResourceClass(), getScimEndpoint()).get().sendRequest();
+        ListResponse<S> resourceTypeListResponse = response.getResource();
+        return resourceTypeListResponse.getListedResources();
+    }
 }
diff --git a/src/main/java/sh/libre/scim/core/ScimClientInterface.java b/src/main/java/sh/libre/scim/core/ScimClientInterface.java
deleted file mode 100644
index 5316bf58a5cdc59759b0a7b335b58ad95c381965..0000000000000000000000000000000000000000
--- a/src/main/java/sh/libre/scim/core/ScimClientInterface.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package sh.libre.scim.core;
-
-import org.keycloak.models.RoleMapperModel;
-import org.keycloak.storage.user.SynchronizationResult;
-
-/**
- * An interface for defining ScimClient.
- * A ScimClient provides methods for propagating CRUD and sync of a dedicated SCIM Resource (e.g. {@link de.captaingoldfish.scim.sdk.common.resources.User}). to a Scim endpoint defined in a {@link sh.libre.scim.storage.ScimStorageProvider}.
- *
- * @param <M> the keycloack model to synchronize (e.g. UserModel or GroupModel)
- */
-public interface ScimClientInterface<M extends RoleMapperModel> extends AutoCloseable {
-
-    /**
-     * Propagates the creation of the given keycloack model to a Scim endpoint.
-     *
-     * @param resource the created resource to propagate (e.g. a new UserModel)
-     */
-    // TODO rename method (e.g. propagateCreation)
-    void create(M resource);
-
-    /**
-     * Propagates the update of the given keycloack model to a Scim endpoint.
-     *
-     * @param resource the resource creation to propagate (e.g. a UserModel)
-     */
-    void replace(M resource);
-
-    /**
-     * Propagates the deletion of an element to a Scim endpoint.
-     *
-     * @param id the deleted resource's id to propagate (e.g. id of a UserModel)
-     */
-    void delete(String id);
-
-    /**
-     * Synchronizes resources between Scim endpoint and keycloack, according to configuration.
-     *
-     * @param result the synchronization result to update for indicating triggered operations (e.g. user deletions)
-     */
-    void sync(SynchronizationResult result);
-
-    /**
-     * @return the {@link ScrimProviderConfiguration} corresponding to this client.
-     */
-    ScrimProviderConfiguration getConfiguration();
-}
diff --git a/src/main/java/sh/libre/scim/core/ScimDispatcher.java b/src/main/java/sh/libre/scim/core/ScimDispatcher.java
index b9070fbfe14d33407c85ce4ee2dd95394a5fdb81..142d3ffe1076c6c061d0ad26cadeacb7c9e914af 100644
--- a/src/main/java/sh/libre/scim/core/ScimDispatcher.java
+++ b/src/main/java/sh/libre/scim/core/ScimDispatcher.java
@@ -22,8 +22,8 @@ public class ScimDispatcher {
     private static final Map<KeycloakSession, ScimDispatcher> sessionToScimDispatcher = new LinkedHashMap<>();
     private final KeycloakSession session;
     private boolean clientsInitialized = false;
-    private final List<UserScimClient> userScimClients = new ArrayList<>();
-    private final List<GroupScimClient> groupScimClients = new ArrayList<>();
+    private final List<UserScimService> userScimServices = new ArrayList<>();
+    private final List<GroupScimService> groupScimServices = new ArrayList<>();
 
 
     public static ScimDispatcher createForSession(KeycloakSession session) {
@@ -42,32 +42,33 @@ public class ScimDispatcher {
     public void refreshActiveScimEndpoints() {
         try {
             // Step 1: close existing clients
-            for (GroupScimClient c : groupScimClients) {
+            for (GroupScimService c : groupScimServices) {
                 c.close();
             }
-            groupScimClients.clear();
-            for (UserScimClient c : userScimClients) {
+            groupScimServices.clear();
+            for (UserScimService c : userScimServices) {
                 c.close();
             }
-            userScimClients.clear();
+            userScimServices.clear();
 
             // Step 2: Get All SCIM endpoints defined in Admin Console (enabled ScimStorageProviderFactory)
             session.getContext().getRealm().getComponentsStream()
                     .filter(m -> ScimStorageProviderFactory.ID.equals(m.getProviderId())
                                  && m.get("enabled", true))
-                    .forEach(scimEndpoint -> {
+                    .forEach(scimEndpointConfigurationRaw -> {
+                        ScrimProviderConfiguration scrimProviderConfiguration = new ScrimProviderConfiguration(scimEndpointConfigurationRaw);
                         try {
                             // Step 3 : create scim clients for each endpoint
-                            if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
-                                GroupScimClient groupScimClient = GroupScimClient.newGroupScimClient(scimEndpoint, session);
-                                groupScimClients.add(groupScimClient);
+                            if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_GROUP, false)) {
+                                GroupScimService groupScimService = new GroupScimService(session, scrimProviderConfiguration);
+                                groupScimServices.add(groupScimService);
                             }
-                            if (scimEndpoint.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
-                                UserScimClient userScimClient = UserScimClient.newUserScimClient(scimEndpoint, session);
-                                userScimClients.add(userScimClient);
+                            if (scimEndpointConfigurationRaw.get(ScrimProviderConfiguration.CONF_KEY_PROPAGATION_USER, false)) {
+                                UserScimService userScimService = new UserScimService(session, scrimProviderConfiguration);
+                                userScimServices.add(userScimService);
                             }
                         } catch (Exception e) {
-                            logger.warnf("[SCIM] Invalid Endpoint configuration %s : %s", scimEndpoint.getId(), e.getMessage());
+                            logger.warnf("[SCIM] Invalid Endpoint configuration %s : %s", scimEndpointConfigurationRaw.getId(), e.getMessage());
                             // TODO is it ok to log and try to create the other clients ?
                         }
                     });
@@ -77,22 +78,22 @@ public class ScimDispatcher {
         }
     }
 
-    public void dispatchUserModificationToAll(Consumer<UserScimClient> operationToDispatch) {
+    public void dispatchUserModificationToAll(Consumer<UserScimService> operationToDispatch) {
         initializeClientsIfNeeded();
-        userScimClients.forEach(operationToDispatch);
-        logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimClients.size());
+        userScimServices.forEach(operationToDispatch);
+        logger.infof("[SCIM] User operation dispatched to %d SCIM clients", userScimServices.size());
     }
 
-    public void dispatchGroupModificationToAll(Consumer<GroupScimClient> operationToDispatch) {
+    public void dispatchGroupModificationToAll(Consumer<GroupScimService> operationToDispatch) {
         initializeClientsIfNeeded();
-        groupScimClients.forEach(operationToDispatch);
-        logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimClients.size());
+        groupScimServices.forEach(operationToDispatch);
+        logger.infof("[SCIM] Group operation dispatched to %d SCIM clients", groupScimServices.size());
     }
 
-    public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimClient> operationToDispatch) {
+    public void dispatchUserModificationToOne(ComponentModel scimServerConfiguration, Consumer<UserScimService> operationToDispatch) {
         initializeClientsIfNeeded();
         // Scim client should already have been created
-        Optional<UserScimClient> matchingClient = userScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
+        Optional<UserScimService> matchingClient = userScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
         if (matchingClient.isPresent()) {
             operationToDispatch.accept(matchingClient.get());
             logger.infof("[SCIM] User operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId());
@@ -102,10 +103,10 @@ public class ScimDispatcher {
     }
 
 
-    public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer<GroupScimClient> operationToDispatch) {
+    public void dispatchGroupModificationToOne(ComponentModel scimServerConfiguration, Consumer<GroupScimService> operationToDispatch) {
         initializeClientsIfNeeded();
         // Scim client should already have been created
-        Optional<GroupScimClient> matchingClient = groupScimClients.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
+        Optional<GroupScimService> matchingClient = groupScimServices.stream().filter(u -> u.getConfiguration().getId().equals(scimServerConfiguration.getId())).findFirst();
         if (matchingClient.isPresent()) {
             operationToDispatch.accept(matchingClient.get());
             logger.infof("[SCIM] Group operation dispatched to SCIM client %s", matchingClient.get().getConfiguration().getId());
@@ -116,14 +117,14 @@ public class ScimDispatcher {
 
     public void close() throws Exception {
         sessionToScimDispatcher.remove(session);
-        for (GroupScimClient c : groupScimClients) {
+        for (GroupScimService c : groupScimServices) {
             c.close();
         }
-        for (UserScimClient c : userScimClients) {
+        for (UserScimService c : userScimServices) {
             c.close();
         }
-        groupScimClients.clear();
-        userScimClients.clear();
+        groupScimServices.clear();
+        userScimServices.clear();
     }
 
     private void initializeClientsIfNeeded() {
diff --git a/src/main/java/sh/libre/scim/core/ScimResourceType.java b/src/main/java/sh/libre/scim/core/ScimResourceType.java
new file mode 100644
index 0000000000000000000000000000000000000000..23df9f8c881f4d93c3bb30d3e2fcf23e4e932807
--- /dev/null
+++ b/src/main/java/sh/libre/scim/core/ScimResourceType.java
@@ -0,0 +1,29 @@
+package sh.libre.scim.core;
+
+import de.captaingoldfish.scim.sdk.common.resources.Group;
+import de.captaingoldfish.scim.sdk.common.resources.ResourceNode;
+import de.captaingoldfish.scim.sdk.common.resources.User;
+
+public enum ScimResourceType {
+
+    USER("/Users", User.class),
+
+    GROUP("/Groups", Group.class);
+
+    private final String endpoint;
+
+    private final Class<? extends ResourceNode> resourceClass;
+
+    ScimResourceType(String endpoint, Class<? extends ResourceNode> resourceClass) {
+        this.endpoint = endpoint;
+        this.resourceClass = resourceClass;
+    }
+
+    public String getEndpoint() {
+        return endpoint;
+    }
+
+    public <T extends ResourceNode> Class<T> getResourceClass() {
+        return (Class<T>) resourceClass;
+    }
+}
diff --git a/src/main/java/sh/libre/scim/core/UserAdapter.java b/src/main/java/sh/libre/scim/core/UserAdapter.java
deleted file mode 100644
index 1de86b3b72ddf311e86460a83f34eb7c25c9aca9..0000000000000000000000000000000000000000
--- a/src/main/java/sh/libre/scim/core/UserAdapter.java
+++ /dev/null
@@ -1,239 +0,0 @@
-package sh.libre.scim.core;
-
-import de.captaingoldfish.scim.sdk.common.resources.User;
-import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
-import de.captaingoldfish.scim.sdk.common.resources.complex.Name;
-import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Email;
-import de.captaingoldfish.scim.sdk.common.resources.multicomplex.PersonRole;
-import org.apache.commons.lang3.BooleanUtils;
-import org.apache.commons.lang3.StringUtils;
-import org.jboss.logging.Logger;
-import org.keycloak.models.KeycloakSession;
-import org.keycloak.models.UserModel;
-
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.stream.Stream;
-
-public class UserAdapter extends Adapter<UserModel, User> {
-
-    private String username;
-    private String displayName;
-    private String email;
-    private Boolean active;
-    private String[] roles;
-    private String firstName;
-    private String lastName;
-
-    public UserAdapter(KeycloakSession session, String componentId) {
-        super(session, componentId, "User", Logger.getLogger(UserAdapter.class));
-    }
-
-    public String getUsername() {
-        return username;
-    }
-
-    public void setUsername(String username) {
-        if (this.username == null) {
-            this.username = username;
-        }
-    }
-
-    public String getDisplayName() {
-        return displayName;
-    }
-
-    public void setDisplayName(String displayName) {
-        if (this.displayName == null) {
-            this.displayName = displayName;
-        }
-    }
-
-    public String getEmail() {
-        return email;
-    }
-
-    public void setEmail(String email) {
-        if (this.email == null) {
-            this.email = email;
-        }
-    }
-
-    public Boolean getActive() {
-        return active;
-    }
-
-    public void setActive(Boolean active) {
-        if (this.active == null) {
-            this.active = active;
-        }
-    }
-
-    public String[] getRoles() {
-        return roles;
-    }
-
-    public void setRoles(String[] roles) {
-        this.roles = roles;
-    }
-
-    public String getFirstName() {
-        return firstName;
-    }
-
-    public void setFirstName(String firstName) {
-        this.firstName = firstName;
-    }
-
-    public String getLastName() {
-        return lastName;
-    }
-
-    public void setLastName(String lastName) {
-        this.lastName = lastName;
-    }
-
-    @Override
-    public Class<User> getResourceClass() {
-        return User.class;
-    }
-
-    @Override
-    public void apply(UserModel user) {
-        setId(user.getId());
-        setUsername(user.getUsername());
-        String displayName = String.format("%s %s", StringUtils.defaultString(user.getFirstName()),
-                StringUtils.defaultString(user.getLastName())).trim();
-        if (StringUtils.isEmpty(displayName)) {
-            displayName = user.getUsername();
-        }
-        setDisplayName(displayName);
-        setEmail(user.getEmail());
-        setActive(user.isEnabled());
-        setFirstName(user.getFirstName());
-        setLastName(user.getLastName());
-        Set<String> rolesSet = new HashSet<>();
-        user.getGroupsStream().flatMap(g -> g.getRoleMappingsStream())
-                .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim")))
-                .map((r) -> r.getName())
-                .forEach(r -> rolesSet.add(r));
-        user.getRoleMappingsStream()
-                .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim")))
-                .map((r) -> r.getName())
-                .forEach(r -> rolesSet.add(r));
-        String[] roles = new String[rolesSet.size()];
-        rolesSet.toArray(roles);
-        setRoles(roles);
-        this.skip = BooleanUtils.TRUE.equals(user.getFirstAttribute("scim-skip"));
-    }
-
-    @Override
-    public void apply(User user) {
-        setExternalId(user.getId().get());
-        setUsername(user.getUserName().get());
-        setDisplayName(user.getDisplayName().get());
-        setActive(user.isActive().get());
-        if (!user.getEmails().isEmpty()) {
-            setEmail(user.getEmails().get(0).getValue().get());
-        }
-    }
-
-    @Override
-    public User toScim() {
-        User user = new User();
-        user.setExternalId(id);
-        user.setUserName(username);
-        user.setId(externalId);
-        user.setDisplayName(displayName);
-        Name name = new Name();
-        name.setFamilyName(lastName);
-        name.setGivenName(firstName);
-        user.setName(name);
-        List<Email> emails = new ArrayList<>();
-        if (email != null) {
-            emails.add(
-                    Email.builder().value(getEmail()).build());
-        }
-        user.setEmails(emails);
-        user.setActive(active);
-        Meta meta = new Meta();
-        try {
-            URI uri = new URI("Users/" + externalId);
-            meta.setLocation(uri.toString());
-        } catch (URISyntaxException e) {
-            logger.warn(e);
-        }
-        user.setMeta(meta);
-        List<PersonRole> roles = new ArrayList<>();
-        for (String role : this.roles) {
-            PersonRole personRole = new PersonRole();
-            personRole.setValue(role);
-            roles.add(personRole);
-        }
-        user.setRoles(roles);
-        return user;
-    }
-
-    @Override
-    public void createEntity() throws Exception {
-        if (StringUtils.isEmpty(username)) {
-            throw new Exception("can't create user with empty username");
-        }
-        UserModel user = session.users().addUser(realm, username);
-        user.setEmail(email);
-        user.setEnabled(active);
-        this.id = user.getId();
-    }
-
-    @Override
-    public Boolean entityExists() {
-        if (this.id == null) {
-            return false;
-        }
-        UserModel user = session.users().getUserById(realm, id);
-        return user != null;
-    }
-
-    @Override
-    public Boolean tryToMap() {
-        UserModel sameUsernameUser = null;
-        UserModel sameEmailUser = null;
-        if (username != null) {
-            sameUsernameUser = session.users().getUserByUsername(realm, username);
-        }
-        if (email != null) {
-            sameEmailUser = session.users().getUserByEmail(realm, email);
-        }
-        if ((sameUsernameUser != null && sameEmailUser != null)
-                && (!StringUtils.equals(sameUsernameUser.getId(), sameEmailUser.getId()))) {
-            logger.warnf("found 2 possible users for remote user %s %s", username, email);
-            return false;
-        }
-        if (sameUsernameUser != null) {
-            this.id = sameUsernameUser.getId();
-            return true;
-        }
-        if (sameEmailUser != null) {
-            this.id = sameEmailUser.getId();
-            return true;
-        }
-        return false;
-    }
-
-    @Override
-    public Stream<UserModel> getResourceStream() {
-        Map<String, String> params = new HashMap<>();
-        return this.session.users().searchForUserStream(realm, params);
-    }
-
-    @Override
-    public Boolean skipRefresh() {
-        return "admin".equals(getUsername());
-    }
-}
diff --git a/src/main/java/sh/libre/scim/core/UserScimClient.java b/src/main/java/sh/libre/scim/core/UserScimClient.java
deleted file mode 100644
index 8d2f25279b291f7143e5fadff98dd2c3f68112a0..0000000000000000000000000000000000000000
--- a/src/main/java/sh/libre/scim/core/UserScimClient.java
+++ /dev/null
@@ -1,288 +0,0 @@
-package sh.libre.scim.core;
-
-import com.google.common.net.HttpHeaders;
-import de.captaingoldfish.scim.sdk.client.ScimClientConfig;
-import de.captaingoldfish.scim.sdk.client.ScimRequestBuilder;
-import de.captaingoldfish.scim.sdk.client.response.ServerResponse;
-import de.captaingoldfish.scim.sdk.common.exceptions.ResponseException;
-import de.captaingoldfish.scim.sdk.common.resources.User;
-import de.captaingoldfish.scim.sdk.common.response.ListResponse;
-import io.github.resilience4j.core.IntervalFunction;
-import io.github.resilience4j.retry.Retry;
-import io.github.resilience4j.retry.RetryConfig;
-import io.github.resilience4j.retry.RetryRegistry;
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.NoResultException;
-import jakarta.ws.rs.ProcessingException;
-import org.jboss.logging.Logger;
-import org.keycloak.component.ComponentModel;
-import org.keycloak.connections.jpa.JpaConnectionProvider;
-import org.keycloak.models.KeycloakSession;
-import org.keycloak.models.UserModel;
-import org.keycloak.storage.user.SynchronizationResult;
-import sh.libre.scim.jpa.ScimResource;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Map;
-
-public class UserScimClient implements ScimClientInterface<UserModel> {
-
-    private static final Logger LOGGER = Logger.getLogger(UserScimClient.class);
-
-    private final ScimRequestBuilder scimRequestBuilder;
-
-    private final RetryRegistry retryRegistry;
-
-    private final KeycloakSession keycloakSession;
-
-    private final ScrimProviderConfiguration scimProviderConfiguration;
-
-    /**
-     * Builds a new {@link UserScimClient}
-     *
-     * @param scimRequestBuilder
-     * @param retryRegistry             Retry policy to use
-     * @param keycloakSession
-     * @param scimProviderConfiguration
-     */
-    private UserScimClient(ScimRequestBuilder scimRequestBuilder, RetryRegistry retryRegistry, KeycloakSession keycloakSession, ScrimProviderConfiguration scimProviderConfiguration) {
-        this.scimRequestBuilder = scimRequestBuilder;
-        this.retryRegistry = retryRegistry;
-        this.keycloakSession = keycloakSession;
-        this.scimProviderConfiguration = scimProviderConfiguration;
-    }
-
-
-    public static UserScimClient newUserScimClient(ComponentModel componentModel, KeycloakSession session) {
-        ScrimProviderConfiguration scimProviderConfiguration = new ScrimProviderConfiguration(componentModel);
-        Map<String, String> httpHeaders = new HashMap<>();
-        httpHeaders.put(HttpHeaders.AUTHORIZATION, scimProviderConfiguration.getAuthorizationHeaderValue());
-        httpHeaders.put(HttpHeaders.CONTENT_TYPE, scimProviderConfiguration.getContentType());
-
-        ScimClientConfig scimClientConfig = ScimClientConfig.builder()
-                .httpHeaders(httpHeaders)
-                .connectTimeout(5)
-                .requestTimeout(5)
-                .socketTimeout(5)
-                .expectedHttpResponseHeaders(Collections.emptyMap()) // strange, useful?
-                // TODO Question Indiehoster : should we really allow connection with TLS ? .hostnameVerifier((s, sslSession) -> true)
-                .build();
-
-        String scimApplicationBaseUrl = scimProviderConfiguration.getEndPoint();
-        ScimRequestBuilder scimRequestBuilder =
-                new ScimRequestBuilder(
-                        scimApplicationBaseUrl,
-                        scimClientConfig
-                );
-
-        RetryConfig retryConfig = RetryConfig.custom()
-                .maxAttempts(10)
-                .intervalFunction(IntervalFunction.ofExponentialBackoff())
-                .retryExceptions(ProcessingException.class)
-                .build();
-
-        RetryRegistry retryRegistry = RetryRegistry.of(retryConfig);
-        return new UserScimClient(scimRequestBuilder, retryRegistry, session, scimProviderConfiguration);
-    }
-
-    @Override
-    public void create(UserModel userModel) {
-        UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
-        adapter.apply(userModel);
-        if (adapter.skip)
-            return;
-        // If mapping exist then it was created by import so skip.
-        if (adapter.query("findById", adapter.getId()).getResultList().isEmpty()) {
-            return;
-        }
-        Retry retry = retryRegistry.retry("create-" + adapter.getId());
-        ServerResponse<User> response = retry.executeSupplier(() -> {
-            try {
-                return scimRequestBuilder
-                        .create(adapter.getResourceClass(), adapter.getScimEndpoint())
-                        .setResource(adapter.toScim())
-                        .sendRequest();
-            } catch (ResponseException e) {
-                throw new RuntimeException(e);
-            }
-        });
-
-        if (!response.isSuccess()) {
-            LOGGER.warn(response.getResponseBody());
-            LOGGER.warn(response.getHttpStatus());
-        }
-
-        adapter.apply(response.getResource());
-        adapter.saveMapping();
-    }
-
-    @Override
-    public void replace(UserModel userModel) {
-        UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
-        try {
-            adapter.apply(userModel);
-            if (adapter.skip)
-                return;
-            ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult();
-            adapter.apply(resource);
-            Retry retry = retryRegistry.retry("replace-" + adapter.getId());
-            ServerResponse<User> response = retry.executeSupplier(() -> {
-                try {
-                    return scimRequestBuilder
-                            .update(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId())
-                            .setResource(adapter.toScim())
-                            .sendRequest();
-                } catch (ResponseException e) {
-                    throw new RuntimeException(e);
-                }
-            });
-            if (!response.isSuccess()) {
-                LOGGER.warn(response.getResponseBody());
-                LOGGER.warn(response.getHttpStatus());
-            }
-        } catch (NoResultException e) {
-            LOGGER.warnf("failed to replace resource %s, scim mapping not found", adapter.getId());
-        } catch (Exception e) {
-            LOGGER.error(e);
-        }
-    }
-
-    @Override
-    public void delete(String id) {
-        UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
-        adapter.setId(id);
-
-        try {
-            ScimResource resource = adapter.query("findById", adapter.getId()).getSingleResult();
-            adapter.apply(resource);
-
-            Retry retry = retryRegistry.retry("delete-" + id);
-            ServerResponse<User> response = retry.executeSupplier(() -> {
-                try {
-                    return scimRequestBuilder.delete(adapter.getResourceClass(), adapter.getScimEndpoint(), adapter.getExternalId())
-                            .sendRequest();
-                } catch (ResponseException e) {
-                    throw new RuntimeException(e);
-                }
-            });
-
-            if (!response.isSuccess()) {
-                LOGGER.warn(response.getResponseBody());
-                LOGGER.warn(response.getHttpStatus());
-            }
-            EntityManager entityManager = this.keycloakSession.getProvider(JpaConnectionProvider.class).getEntityManager();
-            entityManager.remove(resource);
-        } catch (NoResultException e) {
-            LOGGER.warnf("Failed to delete resource %s, scim mapping not found", id);
-        }
-    }
-
-    public void refreshResources(
-            SynchronizationResult syncRes) {
-        LOGGER.info("Refresh resources");
-        UserAdapter a = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
-        a.getResourceStream().forEach(resource -> {
-            UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
-            adapter.apply(resource);
-            LOGGER.infof("Reconciling local resource %s", adapter.getId());
-            if (!adapter.skipRefresh()) {
-                ScimResource mapping = adapter.getMapping();
-                if (mapping == null) {
-                    LOGGER.info("Creating it");
-                    this.create(resource);
-                } else {
-                    LOGGER.info("Replacing it");
-                    this.replace(resource);
-                }
-                syncRes.increaseUpdated();
-            }
-        });
-
-    }
-
-    public void importResources(SynchronizationResult syncRes) {
-        LOGGER.info("Import");
-        try {
-            UserAdapter adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
-            ServerResponse<ListResponse<User>> response = scimRequestBuilder.list(adapter.getResourceClass(), adapter.getScimEndpoint()).get().sendRequest();
-            ListResponse<User> resourceTypeListResponse = response.getResource();
-
-            for (User resource : resourceTypeListResponse.getListedResources()) {
-                try {
-                    LOGGER.infof("Reconciling remote resource %s", resource);
-                    adapter = new UserAdapter(this.keycloakSession, this.scimProviderConfiguration.getId());
-                    adapter.apply(resource);
-
-                    ScimResource mapping = adapter.getMapping();
-                    if (mapping != null) {
-                        adapter.apply(mapping);
-                        if (adapter.entityExists()) {
-                            LOGGER.info("Valid mapping found, skipping");
-                            continue;
-                        } else {
-                            LOGGER.info("Delete a dangling mapping");
-                            adapter.deleteMapping();
-                        }
-                    }
-
-                    Boolean mapped = adapter.tryToMap();
-                    if (mapped) {
-                        LOGGER.info("Matched");
-                        adapter.saveMapping();
-                    } else {
-                        switch (this.scimProviderConfiguration.getImportAction()) {
-                            case CREATE_LOCAL:
-                                LOGGER.info("Create local resource");
-                                try {
-                                    adapter.createEntity();
-                                    adapter.saveMapping();
-                                    syncRes.increaseAdded();
-                                } catch (Exception e) {
-                                    LOGGER.error(e);
-                                }
-                                break;
-                            case DELETE_REMOTE:
-                                LOGGER.info("Delete remote resource");
-                                scimRequestBuilder
-                                        .delete(adapter.getResourceClass(), adapter.getScimEndpoint(), resource.getId().get())
-                                        .sendRequest();
-                                syncRes.increaseRemoved();
-                                break;
-                            case NOTHING:
-                                LOGGER.info("Import action set to NOTHING");
-                                break;
-                        }
-                    }
-                } catch (Exception e) {
-                    LOGGER.error(e);
-                    e.printStackTrace();
-                    syncRes.increaseFailed();
-                }
-            }
-        } catch (ResponseException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    @Override
-    public void sync(SynchronizationResult syncRes) {
-        if (this.scimProviderConfiguration.isSyncImport()) {
-            this.importResources(syncRes);
-        }
-        if (this.scimProviderConfiguration.isSyncRefresh()) {
-            this.refreshResources(syncRes);
-        }
-    }
-
-    @Override
-    public ScrimProviderConfiguration getConfiguration() {
-        return this.scimProviderConfiguration;
-    }
-
-
-    @Override
-    public void close() {
-        scimRequestBuilder.close();
-    }
-}
diff --git a/src/main/java/sh/libre/scim/core/UserScimService.java b/src/main/java/sh/libre/scim/core/UserScimService.java
new file mode 100644
index 0000000000000000000000000000000000000000..9a65849cc34f2cf7f3306abe9662e8acdbf581af
--- /dev/null
+++ b/src/main/java/sh/libre/scim/core/UserScimService.java
@@ -0,0 +1,145 @@
+package sh.libre.scim.core;
+
+import de.captaingoldfish.scim.sdk.common.resources.User;
+import de.captaingoldfish.scim.sdk.common.resources.complex.Meta;
+import de.captaingoldfish.scim.sdk.common.resources.complex.Name;
+import de.captaingoldfish.scim.sdk.common.resources.multicomplex.Email;
+import de.captaingoldfish.scim.sdk.common.resources.multicomplex.MultiComplexNode;
+import de.captaingoldfish.scim.sdk.common.resources.multicomplex.PersonRole;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.jboss.logging.Logger;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RoleMapperModel;
+import org.keycloak.models.RoleModel;
+import org.keycloak.models.UserModel;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+public class UserScimService extends AbstractScimService<UserModel, User> {
+    private final Logger logger = Logger.getLogger(UserScimService.class);
+
+    public UserScimService(
+            KeycloakSession keycloakSession,
+            ScrimProviderConfiguration scimProviderConfiguration) {
+        super(keycloakSession, scimProviderConfiguration, ScimResourceType.USER);
+    }
+
+    @Override
+    protected Stream<UserModel> getResourceStream() {
+        return getKeycloakDao().getUsersStream();
+    }
+
+    @Override
+    protected boolean entityExists(KeycloakId keycloakId) {
+        return getKeycloakDao().userExists(keycloakId);
+    }
+
+    @Override
+    protected Optional<KeycloakId> tryToMap(User resource) {
+        String username = resource.getUserName().get();
+        String email = resource.getEmails().stream()
+                .findFirst()
+                .flatMap(MultiComplexNode::getValue)
+                .orElse(null);
+        UserModel sameUsernameUser = null;
+        UserModel sameEmailUser = null;
+        if (username != null) {
+            sameUsernameUser = getKeycloakDao().getUserByUsername(username);
+        }
+        if (email != null) {
+            sameEmailUser = getKeycloakDao().getUserByEmail(email);
+        }
+        if ((sameUsernameUser != null && sameEmailUser != null)
+            && (!StringUtils.equals(sameUsernameUser.getId(), sameEmailUser.getId()))) {
+            logger.warnf("found 2 possible users for remote user %s %s", username, email);
+            return Optional.empty();
+        }
+        if (sameUsernameUser != null) {
+            return Optional.of(getId(sameUsernameUser));
+        }
+        if (sameEmailUser != null) {
+            return Optional.of(getId(sameEmailUser));
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    protected KeycloakId createEntity(User resource) {
+        String username = resource.getUserName().get();
+        if (StringUtils.isEmpty(username)) {
+            throw new IllegalArgumentException("can't create user with empty username");
+        }
+        UserModel user = getKeycloakDao().addUser(username);
+        resource.getEmails().stream()
+                .findFirst()
+                .flatMap(MultiComplexNode::getValue)
+                .ifPresent(user::setEmail);
+        user.setEnabled(resource.isActive().get());
+        return new KeycloakId(user.getId());
+    }
+
+    @Override
+    protected boolean isSkip(UserModel userModel) {
+        return BooleanUtils.TRUE.equals(userModel.getFirstAttribute("scim-skip"));
+    }
+
+    @Override
+    protected KeycloakId getId(UserModel userModel) {
+        return new KeycloakId(userModel.getId());
+    }
+
+    @Override
+    protected User toScimForCreation(UserModel roleMapperModel) {
+        String firstAndLastName = String.format("%s %s",
+                StringUtils.defaultString(roleMapperModel.getFirstName()),
+                StringUtils.defaultString(roleMapperModel.getLastName())).trim();
+        String displayName = StringUtils.defaultString(firstAndLastName, roleMapperModel.getUsername());
+        Stream<RoleModel> groupRoleModels = roleMapperModel.getGroupsStream().flatMap(RoleMapperModel::getRoleMappingsStream);
+        Stream<RoleModel> roleModels = roleMapperModel.getRoleMappingsStream();
+        Stream<RoleModel> allRoleModels = Stream.concat(groupRoleModels, roleModels);
+        List<PersonRole> roles = allRoleModels
+                .filter((r) -> BooleanUtils.TRUE.equals(r.getFirstAttribute("scim")))
+                .map(RoleModel::getName)
+                .map(roleName -> {
+                    PersonRole personRole = new PersonRole();
+                    personRole.setValue(roleName);
+                    return personRole;
+                })
+                .toList();
+        User user = new User();
+        user.setRoles(roles);
+        user.setExternalId(roleMapperModel.getId());
+        user.setUserName(roleMapperModel.getUsername());
+        user.setDisplayName(displayName);
+        Name name = new Name();
+        name.setFamilyName(roleMapperModel.getLastName());
+        name.setGivenName(roleMapperModel.getFirstName());
+        user.setName(name);
+        List<Email> emails = new ArrayList<>();
+        if (roleMapperModel.getEmail() != null) {
+            emails.add(
+                    Email.builder().value(roleMapperModel.getEmail()).build());
+        }
+        user.setEmails(emails);
+        user.setActive(roleMapperModel.isEnabled());
+        return user;
+    }
+
+    @Override
+    protected User toScimForReplace(UserModel userModel, EntityOnRemoteScimId externalId) {
+        User user = toScimForCreation(userModel);
+        user.setId(externalId.asString());
+        Meta meta = newMetaLocation(externalId);
+        user.setMeta(meta);
+        return user;
+    }
+
+    @Override
+    protected boolean isSkipRefresh(UserModel userModel) {
+        return "admin".equals(userModel.getUsername());
+    }
+}
diff --git a/src/main/java/sh/libre/scim/jpa/ScimResource.java b/src/main/java/sh/libre/scim/jpa/ScimResource.java
index 1de3faa04009f1f02fcfaa567e66606e3efc558d..5536a6dd965cc447057738dab647a8813a600332 100644
--- a/src/main/java/sh/libre/scim/jpa/ScimResource.java
+++ b/src/main/java/sh/libre/scim/jpa/ScimResource.java
@@ -7,6 +7,8 @@ import jakarta.persistence.IdClass;
 import jakarta.persistence.NamedQueries;
 import jakarta.persistence.NamedQuery;
 import jakarta.persistence.Table;
+import sh.libre.scim.core.EntityOnRemoteScimId;
+import sh.libre.scim.core.KeycloakId;
 
 @Entity
 @IdClass(ScimResourceId.class)
@@ -76,4 +78,12 @@ public class ScimResource {
     public void setType(String type) {
         this.type = type;
     }
+
+    public KeycloakId getIdAsKeycloakId() {
+        return new KeycloakId(id);
+    }
+
+    public EntityOnRemoteScimId getExternalIdAsEntityOnRemoteScimId() {
+        return new EntityOnRemoteScimId(externalId);
+    }
 }
diff --git a/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java
new file mode 100644
index 0000000000000000000000000000000000000000..7d96b2819d42b320a6f026b47b1b4d5046170d0b
--- /dev/null
+++ b/src/main/java/sh/libre/scim/jpa/ScimResourceDao.java
@@ -0,0 +1,97 @@
+package sh.libre.scim.jpa;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.NoResultException;
+import jakarta.persistence.TypedQuery;
+import org.keycloak.connections.jpa.JpaConnectionProvider;
+import org.keycloak.models.KeycloakSession;
+import sh.libre.scim.core.EntityOnRemoteScimId;
+import sh.libre.scim.core.KeycloakId;
+import sh.libre.scim.core.ScimResourceType;
+
+import java.util.Optional;
+
+public class ScimResourceDao {
+
+    private final String realmId;
+
+    private final String componentId;
+
+    private final EntityManager entityManager;
+
+    private ScimResourceDao(String realmId, String componentId, EntityManager entityManager) {
+        this.realmId = realmId;
+        this.componentId = componentId;
+        this.entityManager = entityManager;
+    }
+
+    public static ScimResourceDao newInstance(KeycloakSession keycloakSession, String componentId) {
+        String realmId = keycloakSession.getContext().getRealm().getId();
+        EntityManager entityManager = keycloakSession.getProvider(JpaConnectionProvider.class).getEntityManager();
+        return new ScimResourceDao(realmId, componentId, entityManager);
+    }
+
+    private EntityManager getEntityManager() {
+        return entityManager;
+    }
+
+    private String getRealmId() {
+        return realmId;
+    }
+
+    private String getComponentId() {
+        return componentId;
+    }
+
+    public void create(KeycloakId id, EntityOnRemoteScimId externalId, ScimResourceType type) {
+        ScimResource entity = new ScimResource();
+        entity.setType(type.name());
+        entity.setExternalId(externalId.asString());
+        entity.setComponentId(componentId);
+        entity.setRealmId(realmId);
+        entity.setId(id.asString());
+        entityManager.persist(entity);
+    }
+
+    private TypedQuery<ScimResource> getScimResourceTypedQuery(String queryName, String id, ScimResourceType type) {
+        return getEntityManager()
+                .createNamedQuery(queryName, ScimResource.class)
+                .setParameter("type", type.name())
+                .setParameter("realmId", getRealmId())
+                .setParameter("componentId", getComponentId())
+                .setParameter("id", id);
+    }
+
+    public Optional<ScimResource> findByExternalId(EntityOnRemoteScimId externalId, ScimResourceType type) {
+        try {
+            return Optional.of(
+                    getScimResourceTypedQuery("findByExternalId", externalId.asString(), type).getSingleResult()
+            );
+        } catch (NoResultException e) {
+            return Optional.empty();
+        }
+    }
+
+    public Optional<ScimResource> findById(KeycloakId keycloakId, ScimResourceType type) {
+        try {
+            return Optional.of(
+                    getScimResourceTypedQuery("findById", keycloakId.asString(), type).getSingleResult()
+            );
+        } catch (NoResultException e) {
+            return Optional.empty();
+        }
+    }
+
+    public Optional<ScimResource> findUserById(KeycloakId id) {
+        return findById(id, ScimResourceType.USER);
+    }
+
+    public Optional<ScimResource> findUserByExternalId(EntityOnRemoteScimId externalId) {
+        return findByExternalId(externalId, ScimResourceType.USER);
+    }
+
+    public void delete(ScimResource resource) {
+        EntityManager entityManager = getEntityManager();
+        entityManager.remove(resource);
+    }
+}